lang — type-safe i18n engine (zero-deps): - Pure engine (createEngineLang) with explicit locale per call - Svelte 5 reactive wrapper (createActiveLang) via $state - Fallback chain, Intl.PluralRules, cross-key references, JSON round-trip - Type-safe paths with inference from schema logr — structured logger (zero-deps, decoupled from i18n): - 6 levels (TRACE..FATAL) aligned with pino/Log4j/OpenTelemetry/Sentry - Auto-generated entry.id (UUID v4 / hex fallback) - globalContext, child(), time/timeEnd, lazy messages, source capture - Dynamic transports with per-sink minLevel/filter, buffer + writeBatch - Hybrid dispatch: sync loop, transports may be async - Failure routing with deniedFor and cascade guard - Adapters: console, http, callback, sentry, datadog, logtail, loki, otel Test pages at /test/lang and /test/logr. 180 unit tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>master
commit
a6077dd357
@ -0,0 +1,36 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# IDE / tool state (personal, not shared)
|
||||
.claude/settings.local.json
|
||||
.idea/workspace.xml
|
||||
.idea/shelf/
|
||||
.idea/tasks.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/dictionaries/
|
||||
.idea/libraries/
|
||||
.idea/dataSources/
|
||||
.idea/dataSources.local.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@ -0,0 +1,63 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<HTMLCodeStyleSettings>
|
||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||
</HTMLCodeStyleSettings>
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<VueCodeStyleSettings>
|
||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||
</VueCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<option name="SOFT_MARGINS" value="100" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="SOFT_MARGINS" value="100" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="SOFT_MARGINS" value="100" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Vue">
|
||||
<option name="SOFT_MARGINS" value="100" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/active.iml" filepath="$PROJECT_DIR$/.idea/active.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
</component>
|
||||
</project>
|
||||
@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import path from 'node:path';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Override or add rule settings here, such as:
|
||||
// 'svelte/button-has-type': 'error'
|
||||
rules: {}
|
||||
}
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "active",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/node": "^22",
|
||||
"@vitest/browser-playwright": "^4.1.3",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.0",
|
||||
"globals": "^17.4.0",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.7",
|
||||
"vitest": "^4.1.3",
|
||||
"vitest-browser-svelte": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "^10.50.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,351 @@
|
||||
# lang
|
||||
|
||||
Type-safe i18n library for SvelteKit. **Zero external dependencies.** Reactive
|
||||
translation resolution with fallback chain, pluralization via
|
||||
`Intl.PluralRules`, cross-key references, and full JSON serialization.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lang/
|
||||
├── index.ts Barrel exports
|
||||
├── types.ts LangRecord, LangNode, EngineLangInstance, ActiveLangInstance, ...
|
||||
├── engine-lang.ts Pure factory: createEngineLang()
|
||||
├── active-lang.svelte.ts Svelte 5 reactive wrapper: createActiveLang()
|
||||
├── plural.ts p() helper, WeakMap-based config storage
|
||||
├── plural_rules.ts Intl.PluralRules wrapper
|
||||
├── guards.ts Type guards: isLangRef, isLangRecord, isLangString
|
||||
├── helpers.ts resolvePath, interpolateTemplate, resolveRecordFallback, deepMerge
|
||||
├── json.ts langNodeToJSON / JSONToLangNode (round-trip)
|
||||
├── consts.ts ID_PREFIX (#?), separators, limits
|
||||
├── errors.ts Centralized error messages
|
||||
└── test/
|
||||
└── lang.test.ts Unit tests (vitest, node env)
|
||||
```
|
||||
|
||||
### Design principles
|
||||
|
||||
1. **Pure engine** — `createEngineLang()` has no reactive state of its own.
|
||||
`t()` and `ts()` **require** an explicit `locale`. No hidden mutation.
|
||||
2. **Reactive wrapper** — `createActiveLang()` (in `.svelte.ts`) owns the
|
||||
locale via `$state`, injecting it into each engine call. The engine stays
|
||||
framework-agnostic so non-Svelte consumers can build their own adapter.
|
||||
3. **Fallback chain** — `[currentLocale, ...fallbackChain, defaultLocale]`.
|
||||
4. **Type-safe paths** — `t('common.ok')` autocompletes and validates types.
|
||||
A dynamic `string` overload is available for runtime paths.
|
||||
5. **Decoupled plural data** — `p()` lives in `plural.ts`, not in the engine;
|
||||
translation data never imports from the engine.
|
||||
|
||||
---
|
||||
|
||||
## Alias
|
||||
|
||||
Configured in `svelte.config.js`:
|
||||
|
||||
```js
|
||||
alias: { $lang: 'src/arts/lang' }
|
||||
```
|
||||
|
||||
Imports:
|
||||
|
||||
```ts
|
||||
import { createEngineLang } from '$lang';
|
||||
import { createActiveLang } from '$lang/active-lang.svelte';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
A schema is a tree of `LangNode` with four leaf types:
|
||||
|
||||
### LangRecord — static translations
|
||||
|
||||
```ts
|
||||
{ es: 'Aceptar', en: 'OK', ar: 'حسناً' }
|
||||
```
|
||||
|
||||
All locale keys are optional — no locale is hardcoded.
|
||||
|
||||
### LangFn — parametric interpolation
|
||||
|
||||
```ts
|
||||
(params: { name: string }) => ({
|
||||
es: `Hola, ${params.name}`,
|
||||
en: `Hello, ${params.name}`
|
||||
});
|
||||
```
|
||||
|
||||
At serialization time (`langNodeToJSON`), a proxy captures the tokens so the
|
||||
JSON contains `"Hola, {{name}}"` regardless of whether the function uses
|
||||
`${...}` or `{{...}}`.
|
||||
|
||||
### LangPluralFn — pluralization with `p()`
|
||||
|
||||
```ts
|
||||
import { p } from '$lang';
|
||||
|
||||
p({
|
||||
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
|
||||
en: { one: '{{count}} message', other: '{{count}} messages' },
|
||||
ar: {
|
||||
zero: 'لا توجد رسائل',
|
||||
one: 'رسالة واحدة',
|
||||
two: 'رسالتان',
|
||||
few: '{{count}} رسائل',
|
||||
many: '{{count}} رسالة',
|
||||
other: '{{count}} رسالة'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Internally uses `Intl.PluralRules`. Forms: `zero`, `one`, `two`, `few`,
|
||||
`many`, `other`. Only `other` is required; missing forms fall back to
|
||||
`other`.
|
||||
|
||||
### LangRef — alias to another key
|
||||
|
||||
```ts
|
||||
'#?common.ok'; // resolves the value at `common.ok`
|
||||
'#?common.ok|fallback'; // literal fallback if the key does not exist
|
||||
```
|
||||
|
||||
Prefix `#?`, separator `|` for the fallback literal. Up to 3 levels of
|
||||
indirection; cycles throw.
|
||||
|
||||
### Full example
|
||||
|
||||
```ts
|
||||
import { p } from '$lang';
|
||||
import type { LangNode } from '$lang';
|
||||
|
||||
export const translations = {
|
||||
common: {
|
||||
ok: { es: 'Aceptar', en: 'OK' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel' }
|
||||
},
|
||||
greet: (params: { name: string }) => ({
|
||||
es: `Hola, ${params.name}`,
|
||||
en: `Hello, ${params.name}`
|
||||
}),
|
||||
messages: {
|
||||
unread: p({
|
||||
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
|
||||
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
|
||||
})
|
||||
},
|
||||
ref: '#?common.ok'
|
||||
} satisfies LangNode;
|
||||
|
||||
export type TranslationSchema = typeof translations;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Pure engine (non-Svelte)
|
||||
|
||||
```ts
|
||||
import { createEngineLang } from '$lang';
|
||||
import { translations } from './translations';
|
||||
|
||||
const lang = createEngineLang(translations, 'es');
|
||||
|
||||
// `t()` and `ts()` require an explicit locale on the pure engine.
|
||||
lang.t('common.ok', undefined, 'es'); // 'Aceptar'
|
||||
lang.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana'
|
||||
lang.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages'
|
||||
|
||||
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
|
||||
lang.ts('#?common.ok', 'es'); // 'Aceptar'
|
||||
```
|
||||
|
||||
### Reactive wrapper for Svelte 5
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { createActiveLang } from '$lang/active-lang.svelte';
|
||||
import { translations } from './translations';
|
||||
|
||||
const lang = createActiveLang(translations, 'es');
|
||||
</script>
|
||||
|
||||
<h1>{lang.t('common.ok')}</h1>
|
||||
<button onclick={() => lang.setLocale('en')}>EN</button>
|
||||
<button onclick={() => lang.setLocale('ar')}>AR</button>
|
||||
```
|
||||
|
||||
`createActiveLang` holds `locale` in `$state`. When you call
|
||||
`setLocale(x)`, every `t()` / `ts()` / `getLocale()` consumed in a
|
||||
component (or inside `$derived` / `$effect`) re-evaluates.
|
||||
|
||||
Signatures on the reactive wrapper make `locale` **optional** — it defaults
|
||||
to the active reactive locale:
|
||||
|
||||
```ts
|
||||
lang.t('common.ok'); // uses active locale
|
||||
lang.t('common.ok', undefined, 'en'); // explicit locale override
|
||||
```
|
||||
|
||||
### Fallback chain
|
||||
|
||||
```ts
|
||||
// Resolution order: currentLocale → en → es (default)
|
||||
const lang = createActiveLang(translations, 'es', ['en']);
|
||||
|
||||
lang.setLocale('fr');
|
||||
lang.t('common.ok'); // fr missing → tries en → 'OK'
|
||||
```
|
||||
|
||||
### Dynamic modules — `extend()`
|
||||
|
||||
Adds translations to the schema at runtime. Supports dotted namespaces and
|
||||
deep-merges existing keys:
|
||||
|
||||
```ts
|
||||
lang.extend('shop', {
|
||||
product: { es: 'Producto', en: 'Product' }
|
||||
});
|
||||
|
||||
lang.t('shop.product'); // 'Producto'
|
||||
|
||||
// Dotted namespace — creates nested structure
|
||||
lang.extend('app.settings', {
|
||||
title: { es: 'Ajustes', en: 'Settings' }
|
||||
});
|
||||
|
||||
// Subscribe to schema changes (triggers re-render in Svelte automatically)
|
||||
const unsub = lang.onSchemaChange(() => {
|
||||
// schema changed
|
||||
});
|
||||
```
|
||||
|
||||
### Child instances — `register()`
|
||||
|
||||
Creates a new instance with the module merged. In the reactive wrapper the
|
||||
child shares locale state with the parent:
|
||||
|
||||
```ts
|
||||
const child = lang.register('admin', {
|
||||
dashboard: { es: 'Panel', en: 'Dashboard' }
|
||||
});
|
||||
|
||||
child.t('admin.dashboard'); // 'Panel'
|
||||
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
|
||||
|
||||
lang.setLocale('en');
|
||||
child.getLocale(); // 'en' (synced)
|
||||
|
||||
child.dispose(); // detach from parent
|
||||
lang.setLocale('es');
|
||||
child.getLocale(); // 'en' (stopped syncing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API — EngineLangInstance
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------ | ----------------------------------------------------------- |
|
||||
| `t(path, params, locale)` | Translate by schema path. Params for interpolation/plural. |
|
||||
| `ts(value, locale)` | Translate a direct `LangString` (record, string or ref). |
|
||||
| `onSchemaChange(fn)` | Subscribe to schema mutations (`extend`). Returns unsub. |
|
||||
| `extend(namespace, module)` | Add a module at runtime via deep-merge. Dotted namespaces. |
|
||||
| `register(namespace, module)` | Return a child instance with the merged module. |
|
||||
| `setLogger(logger)` | Inject a custom logger. Set-once. |
|
||||
| `getDefaultLocale()` | Return the default locale of the instance. |
|
||||
| `getFallbackChain()` | Return the configured fallback chain (or `undefined`). |
|
||||
|
||||
## API — ActiveLangInstance (Svelte)
|
||||
|
||||
In addition to the above (with `locale` made optional on `t` / `ts`):
|
||||
|
||||
| Method | Description |
|
||||
| ------------------------------------ | ----------------------------------------------------------- |
|
||||
| `getLocale()` | Return the active locale (reactive). |
|
||||
| `setLocale(locale)` | Change the active locale. |
|
||||
| `onLocaleChange(fn)` | Subscribe to locale changes. Returns unsub. |
|
||||
| `dispose()` | Detach a child from its parent locale stream. |
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `SupportedLocale` | Union: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` |
|
||||
| `LangRecord` | `{ [K in SupportedLocale]?: string }` — all optional |
|
||||
| `LangString` | `string \| LangRecord \| LangRef` |
|
||||
| `LangNode` | Recursive: `LangValue \| LangBranch` |
|
||||
| `LangValue<P>` | `LangRecord \| LangFn<P> \| LangPluralFn<P> \| LangRef` |
|
||||
| `LangBranch` | `{ [key: string]: LangNode }` — named branch |
|
||||
| `LangRef` | Template literal: `` `#?${string}` `` |
|
||||
| `LangFn<P>` | `(params: P) => LangRecord` |
|
||||
| `LangPluralFn<P>` | `(params: { count: number } & P) => LangRecord` |
|
||||
| `LangParams` | `Record<string, unknown>` — canonical params type |
|
||||
| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` |
|
||||
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
|
||||
| `EngineLangInstance<S>` | Pure engine public interface |
|
||||
| `ActiveLangInstance<S>` | Reactive wrapper public interface |
|
||||
| `LangLogger` | `{ warn, error }` — custom logger interface |
|
||||
|
||||
---
|
||||
|
||||
## Fallback behavior
|
||||
|
||||
When a key is translated, locales are tried in this order:
|
||||
|
||||
```
|
||||
1. currentLocale
|
||||
2. fallbackChain[0]
|
||||
3. fallbackChain[1]
|
||||
4. ...
|
||||
5. defaultLocale
|
||||
```
|
||||
|
||||
Example with `createEngineLang(schema, 'es', ['en'])` and
|
||||
`setLocale('fr')`:
|
||||
|
||||
```
|
||||
key: common.ok → { es: 'Aceptar', en: 'OK' }
|
||||
|
||||
1. fr → missing
|
||||
2. en → 'OK' ✓
|
||||
```
|
||||
|
||||
If every locale is missing, `t()` returns the path (or the `|fallback`
|
||||
literal if present) and `ts()` returns the empty string. In DEV a warning
|
||||
is emitted when a fallback is used.
|
||||
|
||||
---
|
||||
|
||||
## JSON round-trip
|
||||
|
||||
```ts
|
||||
import { langNodeToJSON, JSONToLangNode } from '$lang';
|
||||
|
||||
const json = langNodeToJSON(translations);
|
||||
// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' }
|
||||
// LangPluralFn → { __type: 'plural', config: { ... } }
|
||||
// LangRef → '#?common.ok' (plain string)
|
||||
// LangRecord → { es: 'Aceptar', en: 'OK' } (unchanged)
|
||||
|
||||
const restored = JSONToLangNode(json);
|
||||
// Interpolation and plural functions are reconstructed.
|
||||
// References stay as strings (isLangRef() recognises them).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npx vitest run src/arts/lang/test/lang.test.ts
|
||||
```
|
||||
|
||||
Covers: guards, helpers, plural rules, `p()` with WeakMap, engine (t/ts,
|
||||
locale param, fallback chain, extend, register, setLogger), JSON
|
||||
round-trip, and confirmation that no locale is hardcoded.
|
||||
@ -0,0 +1,103 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
type ActiveLangInstance,
|
||||
createEngineLang,
|
||||
type EngineLangInstance,
|
||||
type LangLogger,
|
||||
type LangNode,
|
||||
type LangParams,
|
||||
type LangString,
|
||||
type SupportedLocale
|
||||
} from './index';
|
||||
|
||||
export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLangInstance };
|
||||
|
||||
/**
|
||||
* Reactive wrapper around the pure lang engine for Svelte 5.
|
||||
*
|
||||
* The engine is stateless regarding locale — this wrapper owns the
|
||||
* `_locale` `$state` and injects it into every `t()`/`ts()` call.
|
||||
* Any `$derived` / `$effect` touching `getLocale()`, `t()` or `ts()`
|
||||
* re-evaluates when the locale changes.
|
||||
*/
|
||||
export function createActiveLang<S extends LangNode>(
|
||||
schema: S,
|
||||
defaultLocale: SupportedLocale = 'es',
|
||||
fallbackChain?: SupportedLocale[]
|
||||
): ActiveLangInstance<S> {
|
||||
const engine = createEngineLang(schema, defaultLocale, fallbackChain);
|
||||
return wrapEngine(engine, defaultLocale);
|
||||
}
|
||||
|
||||
function wrapEngine<S extends LangNode>(
|
||||
engine: EngineLangInstance<S>,
|
||||
initialLocale: SupportedLocale,
|
||||
parentOnLocaleChange?: (fn: (locale: SupportedLocale) => void) => () => void
|
||||
): ActiveLangInstance<S> {
|
||||
let _locale = $state<SupportedLocale>(initialLocale);
|
||||
let _schemaVersion = $state(0);
|
||||
|
||||
const localeListeners = new SvelteSet<(locale: SupportedLocale) => void>();
|
||||
|
||||
const unsubSchema = engine.onSchemaChange(() => {
|
||||
_schemaVersion++;
|
||||
});
|
||||
|
||||
const unsubParent = parentOnLocaleChange?.((locale) => {
|
||||
setLocale(locale);
|
||||
});
|
||||
|
||||
function setLocale(locale: SupportedLocale): void {
|
||||
if (_locale === locale) return;
|
||||
_locale = locale;
|
||||
localeListeners.forEach((fn) => fn(locale));
|
||||
}
|
||||
|
||||
function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
|
||||
localeListeners.add(fn);
|
||||
return () => localeListeners.delete(fn);
|
||||
}
|
||||
|
||||
return {
|
||||
t(path: string, params?: LangParams, locale?: SupportedLocale): string {
|
||||
void _schemaVersion;
|
||||
return engine.t(path, params, locale ?? _locale);
|
||||
},
|
||||
|
||||
ts(value: LangString, locale?: SupportedLocale): string {
|
||||
void _schemaVersion;
|
||||
return engine.ts(value, locale ?? _locale);
|
||||
},
|
||||
|
||||
getLocale(): SupportedLocale {
|
||||
return _locale;
|
||||
},
|
||||
|
||||
setLocale,
|
||||
|
||||
onLocaleChange,
|
||||
|
||||
onSchemaChange(fn: () => void): () => void {
|
||||
return engine.onSchemaChange(fn);
|
||||
},
|
||||
|
||||
extend(namespace: string, module: LangNode): void {
|
||||
engine.extend(namespace, module);
|
||||
},
|
||||
|
||||
register<NS extends string, M extends LangNode>(namespace: NS, module: M) {
|
||||
const childEngine = engine.register(namespace, module);
|
||||
return wrapEngine(childEngine, _locale, onLocaleChange);
|
||||
},
|
||||
|
||||
setLogger(logger: LangLogger): void {
|
||||
engine.setLogger(logger);
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
unsubSchema();
|
||||
unsubParent?.();
|
||||
localeListeners.clear();
|
||||
}
|
||||
} as ActiveLangInstance<S>;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
|
||||
export const ID_PREFIX = '#?';
|
||||
export const ID_FALLBACK_SEPARATOR = '|';
|
||||
export const MAX_RESOLVE_DEEP = 3;
|
||||
export const LOGGER_CATEGORY = 'lang';
|
||||
@ -0,0 +1,240 @@
|
||||
import type {
|
||||
SupportedLocale,
|
||||
LangBranch,
|
||||
LangFn,
|
||||
LangParams,
|
||||
LangRecord,
|
||||
LangString,
|
||||
EngineLangInstance,
|
||||
LangLogger,
|
||||
LangNode
|
||||
} from './types.ts';
|
||||
import { LANG_ERRORS } from './errors.ts';
|
||||
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts';
|
||||
import { isLangRef, isLangRecord } from './guards.ts';
|
||||
import {
|
||||
interpolateTemplate,
|
||||
resolvePath,
|
||||
parseLangRef,
|
||||
parsePathFallback,
|
||||
deepMerge
|
||||
} from './helpers.ts';
|
||||
|
||||
const DEV: boolean =
|
||||
typeof import.meta !== 'undefined' && import.meta.env != null
|
||||
? import.meta.env.DEV
|
||||
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
|
||||
|
||||
export function createEngineLang<S extends LangNode>(
|
||||
schema: S,
|
||||
defaultLocale: SupportedLocale = 'es',
|
||||
fallbackChain?: SupportedLocale[]
|
||||
): EngineLangInstance<S> {
|
||||
let currentSchema: LangNode = schema;
|
||||
let logger: LangLogger = consoleLogger;
|
||||
let loggerSet: boolean = false;
|
||||
|
||||
const schemaListeners = new Set<() => void>();
|
||||
|
||||
function setLogger(external: LangLogger): void {
|
||||
if (loggerSet) {
|
||||
if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET);
|
||||
return;
|
||||
}
|
||||
logger = external;
|
||||
loggerSet = true;
|
||||
}
|
||||
|
||||
function buildChain(locale: SupportedLocale): SupportedLocale[] {
|
||||
if (locale === defaultLocale) return [defaultLocale];
|
||||
const chain: SupportedLocale[] = [locale];
|
||||
for (const loc of fallbackChain ?? []) {
|
||||
if (loc !== locale && loc !== defaultLocale) chain.push(loc);
|
||||
}
|
||||
chain.push(defaultLocale);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function tsRecord(
|
||||
record: LangRecord,
|
||||
locale: SupportedLocale,
|
||||
path?: string,
|
||||
params?: LangParams
|
||||
): string {
|
||||
const chain = buildChain(locale);
|
||||
let translation: string | undefined;
|
||||
let usedLocale: SupportedLocale | undefined;
|
||||
|
||||
for (const loc of chain) {
|
||||
const candidate = record[loc];
|
||||
if (candidate !== undefined) {
|
||||
translation = candidate;
|
||||
usedLocale = loc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (translation === undefined) translation = path ?? '';
|
||||
|
||||
translation = interpolateTemplate(translation, params);
|
||||
|
||||
if (DEV && usedLocale !== undefined && usedLocale !== locale) {
|
||||
const msg = path
|
||||
? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale)
|
||||
: LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale);
|
||||
logger.warn(LOGGER_CATEGORY, msg);
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
type ResolvedLeaf = string | LangRecord | undefined;
|
||||
|
||||
function resolveValue(
|
||||
value: LangNode | string | undefined,
|
||||
params: unknown,
|
||||
locale: SupportedLocale,
|
||||
depth: number,
|
||||
visited?: Set<string>
|
||||
): ResolvedLeaf {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
if (depth > MAX_RESOLVE_DEEP) {
|
||||
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value)));
|
||||
throw new Error('Circular reference in lang');
|
||||
}
|
||||
|
||||
if (isLangRef(value)) {
|
||||
const parsed = parseLangRef(value);
|
||||
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
|
||||
const seen = visited ?? new Set<string>();
|
||||
if (seen.has(path)) {
|
||||
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path));
|
||||
throw new Error('Circular reference in lang');
|
||||
}
|
||||
seen.add(path);
|
||||
const resolved = resolvePath(currentSchema, path);
|
||||
|
||||
if (resolved === undefined) {
|
||||
if (parsed?.fallback !== undefined) {
|
||||
if (DEV)
|
||||
logger.warn(
|
||||
LOGGER_CATEGORY,
|
||||
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
|
||||
);
|
||||
return parsed.fallback;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolveValue(resolved, params, locale, depth + 1, seen);
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return (value as LangFn<unknown>)(params);
|
||||
}
|
||||
|
||||
if (isLangRecord(value)) return value;
|
||||
|
||||
// Plain string or unexpected shape (e.g. raw LangBranch) — no valid translation.
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
const t: EngineLangInstance<S>['t'] = (
|
||||
path: string,
|
||||
params: LangParams | undefined,
|
||||
locale: SupportedLocale
|
||||
): string => {
|
||||
const { path: cleanPath, fallback: pathFallback } = parsePathFallback(path);
|
||||
const rawValue = resolvePath(currentSchema, cleanPath);
|
||||
|
||||
if (rawValue === undefined) {
|
||||
if (pathFallback !== undefined) {
|
||||
return interpolateTemplate(pathFallback, params);
|
||||
}
|
||||
if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath));
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
const finalValue = resolveValue(rawValue, params, locale, 0);
|
||||
|
||||
if (isLangRecord(finalValue)) {
|
||||
return tsRecord(finalValue, locale, cleanPath, params);
|
||||
}
|
||||
|
||||
return finalValue ?? cleanPath;
|
||||
};
|
||||
|
||||
function ts(value: LangString, locale: SupportedLocale): string {
|
||||
if (value == null) return '';
|
||||
const finalValue = resolveValue(value, undefined, locale, 0);
|
||||
if (isLangRecord(finalValue)) return tsRecord(finalValue, locale, undefined, undefined);
|
||||
return typeof finalValue === 'string' ? finalValue : '';
|
||||
}
|
||||
|
||||
function extend(namespace: string, module: LangNode): void {
|
||||
const parts = namespace.split('.');
|
||||
let nested: LangNode = module;
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
nested = { [parts[i]]: nested };
|
||||
}
|
||||
const root = parts[0];
|
||||
const schemaBranch = currentSchema as LangBranch;
|
||||
const existing = schemaBranch[root];
|
||||
const merged: LangNode = isPlainBranch(existing) && isPlainBranch(nested)
|
||||
? deepMerge(existing, nested)
|
||||
: nested;
|
||||
currentSchema = { ...schemaBranch, [root]: merged };
|
||||
schemaListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function register<NS extends string, M extends LangNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
): EngineLangInstance<S & { [K in NS]: M }> {
|
||||
const newSchema = {
|
||||
...(currentSchema as LangBranch),
|
||||
[namespace]: module
|
||||
} as S & { [K in NS]: M };
|
||||
|
||||
return createEngineLang(newSchema, defaultLocale, fallbackChain);
|
||||
}
|
||||
|
||||
function onSchemaChange(fn: () => void): () => void {
|
||||
schemaListeners.add(fn);
|
||||
return () => schemaListeners.delete(fn);
|
||||
}
|
||||
|
||||
function getDefaultLocale(): SupportedLocale {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
function getFallbackChain(): SupportedLocale[] | undefined {
|
||||
return fallbackChain;
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
ts,
|
||||
onSchemaChange,
|
||||
extend,
|
||||
register,
|
||||
setLogger,
|
||||
getDefaultLocale,
|
||||
getFallbackChain
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainBranch(value: unknown): value is LangBranch {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof value !== 'function'
|
||||
);
|
||||
}
|
||||
|
||||
const consoleLogger: LangLogger = {
|
||||
warn: (_category: string, message: string) => DEV && console.warn(message),
|
||||
error: (_category: string, message: string) => DEV && console.error(message)
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
export const LANG_ERRORS = {
|
||||
KEY_NOT_FOUND: (path: string): string => `[lang] Translation key not found: "${path}"`,
|
||||
|
||||
CIRCULAR_REFERENCE: (path: string): string => `[lang] Circular reference in "${path}".`,
|
||||
|
||||
MISSING_TRANSLATION: (path: string, locale: string, usedLocale: string): string =>
|
||||
`[lang] Missing translation for "${path}" in "${locale}". Falling back to "${usedLocale}".`,
|
||||
|
||||
MISSING_TRANSLATION_RECORD: (locale: string, usedLocale: string): string =>
|
||||
`[lang] Missing translation in "${locale}". Falling back to "${usedLocale}".`,
|
||||
|
||||
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.'
|
||||
} as const;
|
||||
@ -0,0 +1,19 @@
|
||||
import type { LangRef, LangRecord, LangString } from './types.ts';
|
||||
import { ID_PREFIX } from './consts.ts';
|
||||
|
||||
export function isLangRef(value: unknown): value is LangRef {
|
||||
return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2;
|
||||
}
|
||||
|
||||
export function isLangRecord(value: unknown): value is LangRecord {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
if (Array.isArray(value)) return false;
|
||||
if (typeof value === 'function') return false;
|
||||
const vals = Object.values(value as Record<string, unknown>);
|
||||
if (vals.length === 0) return false;
|
||||
return vals.every((v) => typeof v === 'string' || v === undefined);
|
||||
}
|
||||
|
||||
export function isLangString(value: unknown): value is LangString {
|
||||
return isLangRef(value) || isLangRecord(value) || typeof value === 'string';
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import type { LangBranch, LangNode, LangParams, LangRecord, LangString, SupportedLocale } from './types.ts';
|
||||
import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
|
||||
import { isLangString } from './guards.ts';
|
||||
|
||||
export function resolvePath(obj: LangNode | undefined, path: string): LangNode | undefined {
|
||||
if (!path || obj == null) return undefined;
|
||||
let current: unknown = obj;
|
||||
for (const key of path.split('.')) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current as LangNode | undefined;
|
||||
}
|
||||
|
||||
export function parseLangRef(value: string): { path: string; fallback?: string } | null {
|
||||
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
|
||||
|
||||
const raw = value.slice(ID_PREFIX.length);
|
||||
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return { path: raw };
|
||||
}
|
||||
|
||||
return {
|
||||
path: raw.slice(0, separatorIndex),
|
||||
fallback: raw.slice(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePathFallback(path: string): { path: string; fallback?: string } {
|
||||
const separatorIndex = path.indexOf(ID_FALLBACK_SEPARATOR);
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return { path };
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.slice(0, separatorIndex),
|
||||
fallback: path.slice(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function makeLangRecord(text: string, locale: SupportedLocale): LangRecord {
|
||||
return { [locale]: text } as LangRecord;
|
||||
}
|
||||
|
||||
export function asLangString(value: unknown): LangString {
|
||||
if (value == null) return '';
|
||||
if (isLangString(value)) return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function interpolateTemplate(text: string, params?: LangParams): string {
|
||||
if (!params) return text;
|
||||
let result = text;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
result = result.replaceAll(`{{${key}}}`, String(val));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveRecordFallback(
|
||||
record: Partial<Record<SupportedLocale, string>>,
|
||||
chain: SupportedLocale[]
|
||||
): string | undefined {
|
||||
for (const locale of chain) {
|
||||
const val = record[locale];
|
||||
if (val !== undefined) return val;
|
||||
}
|
||||
return Object.values(record).find((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
export function deepMerge<T extends LangBranch, S extends LangBranch>(target: T, source: S): T & S {
|
||||
const result: LangBranch = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
const srcVal = source[key];
|
||||
const tgtVal = result[key];
|
||||
if (isPlainBranch(srcVal) && isPlainBranch(tgtVal)) {
|
||||
result[key] = deepMerge(tgtVal, srcVal);
|
||||
} else {
|
||||
result[key] = srcVal;
|
||||
}
|
||||
}
|
||||
return result as T & S;
|
||||
}
|
||||
|
||||
function isPlainBranch(value: unknown): value is LangBranch {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof value !== 'function'
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export * from './consts.ts';
|
||||
export * from './guards.ts';
|
||||
export * from './types.ts';
|
||||
export * from './plural_rules.ts';
|
||||
export * from './plural.ts';
|
||||
export * from './helpers.ts';
|
||||
export * from './engine-lang.ts';
|
||||
export * from './json.ts';
|
||||
export * from './errors.ts';
|
||||
@ -0,0 +1,71 @@
|
||||
import type { LangBranch, LangFn, LangNode, LangParams, PluralConfig } from './types.ts';
|
||||
import { p, getPluralConfig } from './plural.ts';
|
||||
|
||||
export type LangJSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| LangJSONObject
|
||||
| LangJSONValue[];
|
||||
|
||||
export interface LangJSONObject {
|
||||
[key: string]: LangJSONValue;
|
||||
}
|
||||
|
||||
type PluralJSON = LangJSONObject & {
|
||||
__type: 'plural';
|
||||
config: LangJSONObject;
|
||||
};
|
||||
|
||||
function isPluralJSON(value: LangJSONObject): value is PluralJSON {
|
||||
return value.__type === 'plural' && typeof value.config === 'object' && value.config !== null;
|
||||
}
|
||||
|
||||
export function langNodeToJSON(node: LangNode): LangJSONValue {
|
||||
if (typeof node === 'string') return node;
|
||||
|
||||
if (typeof node === 'function') {
|
||||
const config = getPluralConfig(node);
|
||||
if (config) {
|
||||
return {
|
||||
__type: 'plural',
|
||||
config: config as unknown as LangJSONObject
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const proxyParams = new Proxy({} as LangParams, {
|
||||
get: (_, prop) => `{{${String(prop)}}}`
|
||||
});
|
||||
return langNodeToJSON((node as LangFn)(proxyParams));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const result: LangJSONObject = {};
|
||||
const source = node as LangBranch;
|
||||
for (const key in source) {
|
||||
result[key] = langNodeToJSON(source[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function JSONToLangNode(json: LangJSONValue): LangNode {
|
||||
if (typeof json === 'string') return json as LangNode;
|
||||
|
||||
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
||||
throw new TypeError('[lang] JSONToLangNode: invalid JSON shape for LangNode');
|
||||
}
|
||||
|
||||
if (isPluralJSON(json)) {
|
||||
return p(json.config as unknown as PluralConfig);
|
||||
}
|
||||
|
||||
const result: LangBranch = {};
|
||||
for (const key in json) {
|
||||
result[key] = JSONToLangNode(json[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import type { LangParams, LangPluralFn, LangRecord, PluralConfig, PluralForms } from './types.ts';
|
||||
import type { PluralCategory } from './plural_rules.ts';
|
||||
import { pluralRule } from './plural_rules.ts';
|
||||
|
||||
type AnyLangFn = (params: LangParams) => LangRecord;
|
||||
|
||||
const pluralConfigs = new WeakMap<AnyLangFn, PluralConfig>();
|
||||
|
||||
export const p = <P extends LangParams = LangParams>(config: PluralConfig): LangPluralFn<P> => {
|
||||
const cache = new Map<string, LangRecord>();
|
||||
|
||||
const fn: LangPluralFn<P> = (params) => {
|
||||
const key = String(params.count);
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const result: Partial<LangRecord> = {};
|
||||
for (const [locale, forms] of Object.entries(config)) {
|
||||
if (!forms) continue;
|
||||
const rule: PluralCategory = pluralRule(locale, params.count);
|
||||
const typedForms = forms as PluralForms;
|
||||
result[locale as keyof LangRecord] = typedForms[rule] || typedForms.other;
|
||||
}
|
||||
|
||||
const record = result as LangRecord;
|
||||
cache.set(key, record);
|
||||
return record;
|
||||
};
|
||||
|
||||
pluralConfigs.set(fn as unknown as AnyLangFn, config);
|
||||
return fn;
|
||||
};
|
||||
|
||||
export function getPluralConfig(fn: AnyLangFn | LangPluralFn | unknown): PluralConfig | undefined {
|
||||
if (typeof fn !== 'function') return undefined;
|
||||
return pluralConfigs.get(fn as AnyLangFn);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
||||
|
||||
export function pluralRule(locale: string, n: number): PluralCategory {
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
|
||||
try {
|
||||
return new Intl.PluralRules(locale).select(n) as PluralCategory;
|
||||
} catch {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@ -0,0 +1,700 @@
|
||||
/**
|
||||
* lang — test suite
|
||||
*
|
||||
* Covers:
|
||||
* - guards (isLangRef, isLangRecord, isLangString)
|
||||
* - helpers (resolvePath, parseLangRef, parsePathFallback, makeLangRecord,
|
||||
* interpolateTemplate, resolveRecordFallback, deepMerge)
|
||||
* - plural_rules (pluralRule via Intl.PluralRules)
|
||||
* - plural (p, getPluralConfig)
|
||||
* - engine (createEngineLang: t, ts, fallback chain, locale param,
|
||||
* extend, register, setLogger)
|
||||
* - json (langNodeToJSON, JSONToLangNode, round-trip)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isLangRef, isLangRecord, isLangString } from '../guards.ts';
|
||||
import {
|
||||
parseLangRef,
|
||||
parsePathFallback,
|
||||
resolvePath,
|
||||
makeLangRecord,
|
||||
interpolateTemplate,
|
||||
resolveRecordFallback,
|
||||
deepMerge
|
||||
} from '../helpers.ts';
|
||||
import { pluralRule } from '../plural_rules.ts';
|
||||
import { p, getPluralConfig } from '../plural.ts';
|
||||
import { createEngineLang } from '../engine-lang.ts';
|
||||
import { langNodeToJSON, JSONToLangNode } from '../json.ts';
|
||||
import type { LangNode, LangBranch } from '../types.ts';
|
||||
|
||||
// ============================================================================
|
||||
// GUARDS
|
||||
// ============================================================================
|
||||
|
||||
describe('isLangRef', () => {
|
||||
it('returns true for a valid reference', () => {
|
||||
expect(isLangRef('#?common.ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for prefix only without path', () => {
|
||||
expect(isLangRef('#?')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for normal strings', () => {
|
||||
expect(isLangRef('hola')).toBe(false);
|
||||
expect(isLangRef('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-strings', () => {
|
||||
expect(isLangRef(null)).toBe(false);
|
||||
expect(isLangRef(42)).toBe(false);
|
||||
expect(isLangRef({})).toBe(false);
|
||||
expect(isLangRef(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for references with nested paths', () => {
|
||||
expect(isLangRef('#?a.b.c')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLangRecord', () => {
|
||||
it('returns true for a record with es and en', () => {
|
||||
expect(isLangRecord({ es: 'Aceptar', en: 'OK' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with only en — no hardcoded locale required', () => {
|
||||
expect(isLangRecord({ en: 'Hello' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty objects', () => {
|
||||
expect(isLangRecord({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if values are not strings', () => {
|
||||
expect(isLangRecord({ es: 42 })).toBe(false);
|
||||
expect(isLangRecord({ es: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for arrays', () => {
|
||||
expect(isLangRecord(['es', 'Hola'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for functions', () => {
|
||||
expect(isLangRecord(() => ({ es: 'x' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for primitives', () => {
|
||||
expect(isLangRecord(null)).toBe(false);
|
||||
expect(isLangRecord('hola')).toBe(false);
|
||||
expect(isLangRecord(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLangString', () => {
|
||||
it('accepts plain strings', () => {
|
||||
expect(isLangString('hola')).toBe(true);
|
||||
expect(isLangString('')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts LangRecord', () => {
|
||||
expect(isLangString({ en: 'Hello' })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts LangRef', () => {
|
||||
expect(isLangString('#?common.ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects null and undefined', () => {
|
||||
expect(isLangString(null)).toBe(false);
|
||||
expect(isLangString(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects numbers', () => {
|
||||
expect(isLangString(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
describe('parseLangRef', () => {
|
||||
it('extracts path without fallback', () => {
|
||||
expect(parseLangRef('#?common.ok')).toEqual({ path: 'common.ok' });
|
||||
});
|
||||
|
||||
it('extracts path and fallback literal', () => {
|
||||
expect(parseLangRef('#?terra.calendar.month|month')).toEqual({
|
||||
path: 'terra.calendar.month',
|
||||
fallback: 'month'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-ref strings', () => {
|
||||
expect(parseLangRef('terra.calendar.month')).toBeNull();
|
||||
expect(parseLangRef('#?')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePathFallback', () => {
|
||||
it('extracts path without fallback', () => {
|
||||
expect(parsePathFallback('common.ok')).toEqual({ path: 'common.ok' });
|
||||
});
|
||||
|
||||
it('extracts path and fallback', () => {
|
||||
expect(parsePathFallback('home.options|Options')).toEqual({
|
||||
path: 'home.options',
|
||||
fallback: 'Options'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns full string as path when no separator', () => {
|
||||
expect(parsePathFallback('noseparator')).toEqual({ path: 'noseparator' });
|
||||
});
|
||||
|
||||
it('handles empty fallback', () => {
|
||||
expect(parsePathFallback('path|')).toEqual({ path: 'path', fallback: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePath', () => {
|
||||
const obj: LangBranch = {
|
||||
a: { b: { c: { en: 'value' } } }
|
||||
};
|
||||
|
||||
it('resolves nested paths', () => {
|
||||
expect(resolvePath(obj, 'a.b.c')).toEqual({ en: 'value' });
|
||||
});
|
||||
|
||||
it('resolves one level', () => {
|
||||
expect(resolvePath(obj, 'a')).toEqual({ b: { c: { en: 'value' } } });
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent paths', () => {
|
||||
expect(resolvePath(obj, 'a.x.y')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty path', () => {
|
||||
expect(resolvePath(obj, '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeLangRecord', () => {
|
||||
it('creates a record with the specified locale', () => {
|
||||
const record = makeLangRecord('Hola', 'es');
|
||||
expect(record.es).toBe('Hola');
|
||||
});
|
||||
|
||||
it('creates a record with en locale', () => {
|
||||
const record = makeLangRecord('Hello', 'en');
|
||||
expect(record.en).toBe('Hello');
|
||||
expect((record as Record<string, unknown>).es).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateTemplate', () => {
|
||||
it('replaces {{key}} with value', () => {
|
||||
expect(interpolateTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('replaces multiple keys', () => {
|
||||
expect(
|
||||
interpolateTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Ana' })
|
||||
).toBe('Hi Ana');
|
||||
});
|
||||
|
||||
it('returns text unchanged if no params', () => {
|
||||
expect(interpolateTemplate('Hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('handles special regex chars in values', () => {
|
||||
expect(interpolateTemplate('Code: {{code}}', { code: 'A+B' })).toBe('Code: A+B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRecordFallback', () => {
|
||||
it('returns value for first locale in chain', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['en', 'es'])).toBe('Hello');
|
||||
});
|
||||
|
||||
it('falls back through the chain', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es'])).toBe('Hello');
|
||||
});
|
||||
|
||||
it('uses the last available entry', () => {
|
||||
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'de', 'es'])).toBe('Hola');
|
||||
});
|
||||
|
||||
it('returns undefined for empty record', () => {
|
||||
expect(resolveRecordFallback({}, ['es'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses Object.values fallback when chain misses', () => {
|
||||
expect(resolveRecordFallback({ fr: 'Bonjour' }, ['de'])).toBe('Bonjour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge', () => {
|
||||
it('merges flat branches', () => {
|
||||
expect(deepMerge({ a: { en: '1' } }, { b: { en: '2' } })).toEqual({
|
||||
a: { en: '1' },
|
||||
b: { en: '2' }
|
||||
});
|
||||
});
|
||||
|
||||
it('deep merges nested branches', () => {
|
||||
expect(
|
||||
deepMerge({ a: { x: { en: '1' }, y: { en: '2' } } }, { a: { y: { en: '3' }, z: { en: '4' } } })
|
||||
).toEqual({
|
||||
a: { x: { en: '1' }, y: { en: '3' }, z: { en: '4' } }
|
||||
});
|
||||
});
|
||||
|
||||
it('overwrites leaves with source', () => {
|
||||
expect(deepMerge({ a: { en: '1' } }, { a: { en: '2' } })).toEqual({
|
||||
a: { en: '2' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PLURAL RULES (Intl.PluralRules)
|
||||
// ============================================================================
|
||||
|
||||
describe('pluralRule', () => {
|
||||
describe('Spanish (es) — one/other', () => {
|
||||
it('1 → one', () => expect(pluralRule('es', 1)).toBe('one'));
|
||||
it('0 → other', () => expect(pluralRule('es', 0)).toBe('other'));
|
||||
it('2 → other', () => expect(pluralRule('es', 2)).toBe('other'));
|
||||
});
|
||||
|
||||
describe('English (en) — one/other', () => {
|
||||
it('1 → one', () => expect(pluralRule('en', 1)).toBe('one'));
|
||||
it('2 → other', () => expect(pluralRule('en', 2)).toBe('other'));
|
||||
});
|
||||
|
||||
describe('Arabic (ar) — 6 forms', () => {
|
||||
it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero'));
|
||||
it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one'));
|
||||
it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two'));
|
||||
it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few'));
|
||||
it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many'));
|
||||
it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other'));
|
||||
});
|
||||
|
||||
describe('region-normalized locales', () => {
|
||||
it('es-ES → one for 1', () => expect(pluralRule('es-ES', 1)).toBe('one'));
|
||||
it('en-US → one for 1', () => expect(pluralRule('en-US', 1)).toBe('one'));
|
||||
});
|
||||
|
||||
describe('unknown locale — falls back to Intl root', () => {
|
||||
it('xx, 5 → other', () => expect(pluralRule('xx', 5)).toBe('other'));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PLURAL — p() with WeakMap
|
||||
// ============================================================================
|
||||
|
||||
describe('p()', () => {
|
||||
const plural = p({
|
||||
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
|
||||
en: { one: '{{count}} item', other: '{{count}} items' }
|
||||
});
|
||||
|
||||
it('selects singular form for 1', () => {
|
||||
const record = plural({ count: 1 });
|
||||
expect(record.es).toBe('{{count}} elemento');
|
||||
});
|
||||
|
||||
it('selects plural form for 2', () => {
|
||||
const record = plural({ count: 2 });
|
||||
expect(record.es).toBe('{{count}} elementos');
|
||||
});
|
||||
|
||||
it('caches by count', () => {
|
||||
const r1 = plural({ count: 5 });
|
||||
const r2 = plural({ count: 5 });
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
|
||||
it('uses other as fallback when form missing', () => {
|
||||
const fallback = p({ es: { other: 'varios' } });
|
||||
const record = fallback({ count: 1 });
|
||||
expect(record.es).toBe('varios');
|
||||
});
|
||||
|
||||
it('getPluralConfig retrieves config via WeakMap', () => {
|
||||
expect(getPluralConfig(plural)).toBeDefined();
|
||||
expect(getPluralConfig(plural)!.es).toBeDefined();
|
||||
});
|
||||
|
||||
it('getPluralConfig returns undefined for non-plural functions', () => {
|
||||
const regular = () => ({ es: 'x' });
|
||||
expect(getPluralConfig(regular)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores config via WeakMap, not on the function itself', () => {
|
||||
expect((plural as unknown as Record<string, unknown>).__pluralConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE — createEngineLang
|
||||
// ============================================================================
|
||||
|
||||
const schema = {
|
||||
common: {
|
||||
ok: { es: 'Aceptar', en: 'OK' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel' }
|
||||
},
|
||||
greet: (params: { name: string }) => ({
|
||||
es: `Hola, ${params.name}`,
|
||||
en: `Hello, ${params.name}`
|
||||
}),
|
||||
messages: {
|
||||
unread: p({
|
||||
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
|
||||
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
|
||||
})
|
||||
},
|
||||
ref: '#?common.ok',
|
||||
nested: {
|
||||
deep: {
|
||||
value: { es: 'Profundo', en: 'Deep' }
|
||||
}
|
||||
},
|
||||
onlyEn: { en: 'English only' },
|
||||
onlyFr: { fr: 'Francais seulement' }
|
||||
} as const satisfies LangNode;
|
||||
|
||||
describe('createEngineLang — t()', () => {
|
||||
it('translates a simple key to the requested locale', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('translates to alternative locale', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('common.ok', undefined, 'en')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns path when key does not exist', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect((lang.t as (p: string, params: undefined, locale: 'es') => string)('no.existe', undefined, 'es')).toBe(
|
||||
'no.existe'
|
||||
);
|
||||
});
|
||||
|
||||
it('interpolates params', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('greet', { name: 'Ana' }, 'es')).toBe('Hola, Ana');
|
||||
});
|
||||
|
||||
it('resolves LangRef references (#?)', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('ref', undefined, 'es')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('resolves deep nested paths', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('nested.deep.value', undefined, 'es')).toBe('Profundo');
|
||||
});
|
||||
|
||||
it('resolves plural — one', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('messages.unread', { count: 1 }, 'es')).toBe('1 mensaje sin leer');
|
||||
});
|
||||
|
||||
it('resolves plural — other', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.t('messages.unread', { count: 5 }, 'es')).toBe('5 mensajes sin leer');
|
||||
});
|
||||
|
||||
it('returns fallback literal when key missing and | present', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(
|
||||
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'missing.key|Alternate',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('Alternate');
|
||||
});
|
||||
|
||||
it('interpolates params in the fallback literal', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(
|
||||
(lang.t as (p: string, params: Record<string, unknown>, locale: 'es') => string)(
|
||||
'missing.greet|Hola {{name}}',
|
||||
{ name: 'Ana' },
|
||||
'es'
|
||||
)
|
||||
).toBe('Hola Ana');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — ts()', () => {
|
||||
it('translates a LangRecord directly', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'es')).toBe('Directo');
|
||||
});
|
||||
|
||||
it('translates a LangRecord in explicit locale', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'en')).toBe('Direct');
|
||||
});
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts(null as unknown as string, 'es')).toBe('');
|
||||
});
|
||||
|
||||
it('resolves LangRef references', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts('#?common.ok', 'es')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('uses fallback literal when ref key does not exist', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts('#?terra.calendar.month|month', 'es')).toBe('month');
|
||||
});
|
||||
|
||||
it('keeps plain strings as literals', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
expect(lang.ts('plain text', 'es')).toBe('plain text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — fallback chain', () => {
|
||||
it('falls back through the chain: current → chain → default', () => {
|
||||
const lang = createEngineLang(schema, 'es', ['en']);
|
||||
expect(lang.t('common.ok', undefined, 'fr')).toBe('OK');
|
||||
});
|
||||
|
||||
it('falls back to default when chain misses', () => {
|
||||
const lang = createEngineLang(schema, 'es', ['de']);
|
||||
expect(lang.t('common.ok', undefined, 'fr')).toBe('Aceptar');
|
||||
});
|
||||
|
||||
it('skips chain locales equal to current or default', () => {
|
||||
const lang = createEngineLang(schema, 'es', ['es']);
|
||||
expect(lang.t('common.ok', undefined, 'en')).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — extend()', () => {
|
||||
it('adds a simple namespace', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
lang.extend('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
expect(
|
||||
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'shop.product',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('Producto');
|
||||
});
|
||||
|
||||
it('supports dotted namespaces', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } });
|
||||
expect(
|
||||
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'app.settings.title',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('Ajustes');
|
||||
});
|
||||
|
||||
it('deep-merges without overwriting existing keys', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
|
||||
expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar');
|
||||
expect(
|
||||
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'common.extra',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('Extra');
|
||||
});
|
||||
|
||||
it('notifies schema listeners', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
lang.onSchemaChange(spy);
|
||||
lang.extend('shop', { product: { es: 'Producto' } });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsubscribe stops schema notifications', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
const spy = vi.fn();
|
||||
const unsub = lang.onSchemaChange(spy);
|
||||
unsub();
|
||||
lang.extend('shop', { product: { es: 'Producto' } });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — register()', () => {
|
||||
it('returns a new instance with the namespace merged', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
const extended = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
expect(
|
||||
(extended.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'shop.product',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('Producto');
|
||||
});
|
||||
|
||||
it('leaves the original instance unaffected', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
lang.register('shop', { product: { es: 'Producto' } });
|
||||
expect(
|
||||
(lang.t as (p: string, params: undefined, locale: 'es') => string)(
|
||||
'shop.product',
|
||||
undefined,
|
||||
'es'
|
||||
)
|
||||
).toBe('shop.product');
|
||||
});
|
||||
|
||||
it('child inherits fallback chain from the parent', () => {
|
||||
const lang = createEngineLang(schema, 'es', ['en']);
|
||||
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
|
||||
expect(
|
||||
(child.t as (p: string, params: undefined, locale: 'fr') => string)(
|
||||
'shop.product',
|
||||
undefined,
|
||||
'fr'
|
||||
)
|
||||
).toBe('Product');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — setLogger()', () => {
|
||||
it('replaces the default logger', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
const warn = vi.fn();
|
||||
const error = vi.fn();
|
||||
lang.setLogger({ warn, error });
|
||||
expect(lang.getDefaultLocale()).toBe('es');
|
||||
});
|
||||
|
||||
it('ignores subsequent setLogger calls (set-once)', () => {
|
||||
const lang = createEngineLang(schema, 'es');
|
||||
const warn1 = vi.fn();
|
||||
const warn2 = vi.fn();
|
||||
lang.setLogger({ warn: warn1, error: vi.fn() });
|
||||
lang.setLogger({ warn: warn2, error: vi.fn() });
|
||||
expect(lang.getDefaultLocale()).toBe('es');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLang — non-Spanish defaults', () => {
|
||||
it('works with en as default locale', () => {
|
||||
const enSchema = { greeting: { en: 'Hello' } } as const;
|
||||
const lang = createEngineLang(enSchema, 'en');
|
||||
expect(lang.t('greeting', undefined, 'en')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('works with fr as default locale', () => {
|
||||
const frSchema = { greeting: { fr: 'Bonjour' } } as const;
|
||||
const lang = createEngineLang(frSchema, 'fr');
|
||||
expect(lang.t('greeting', undefined, 'fr')).toBe('Bonjour');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// JSON — langNodeToJSON / JSONToLangNode
|
||||
// ============================================================================
|
||||
|
||||
describe('langNodeToJSON', () => {
|
||||
it('serializes LangRecord as plain object', () => {
|
||||
expect(langNodeToJSON({ en: 'Hello' })).toEqual({ en: 'Hello' });
|
||||
});
|
||||
|
||||
it('serializes namespaces recursively', () => {
|
||||
const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } } satisfies LangNode;
|
||||
const result = langNodeToJSON(node) as { common: { ok: Record<string, string> } };
|
||||
expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' });
|
||||
});
|
||||
|
||||
it('serializes interpolation functions with {{tokens}}', () => {
|
||||
const fn = (params: { name: string }) => ({ es: `Hola ${params.name}`, en: `Hello ${params.name}` });
|
||||
const result = langNodeToJSON(fn) as { es: string; en: string };
|
||||
expect(result.es).toBe('Hola {{name}}');
|
||||
expect(result.en).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
it('serializes plural via WeakMap as { __type: "plural", config }', () => {
|
||||
const plural = p({ en: { one: '1 item', other: '{{count}} items' } });
|
||||
const result = langNodeToJSON(plural) as { __type: string; config: unknown };
|
||||
expect(result.__type).toBe('plural');
|
||||
expect(result.config).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSONToLangNode', () => {
|
||||
it('reconstructs LangRecord', () => {
|
||||
const result = JSONToLangNode({ en: 'Hello' });
|
||||
expect(result).toEqual({ en: 'Hello' });
|
||||
});
|
||||
|
||||
it('reconstructs plurals as callable functions', () => {
|
||||
const config = { en: { one: '{{count}} item', other: '{{count}} items' } };
|
||||
const restored = JSONToLangNode({ __type: 'plural', config }) as unknown as (p: {
|
||||
count: number;
|
||||
}) => Record<string, string>;
|
||||
expect(typeof restored).toBe('function');
|
||||
expect(restored({ count: 1 }).en).toBe('{{count}} item');
|
||||
});
|
||||
|
||||
it('reconstructs namespaces recursively', () => {
|
||||
const json = { common: { ok: { en: 'OK' } } };
|
||||
const restored = JSONToLangNode(json) as { common: { ok: { en: string } } };
|
||||
expect(restored.common.ok.en).toBe('OK');
|
||||
});
|
||||
|
||||
it('throws on invalid JSON shape', () => {
|
||||
expect(() => JSONToLangNode(42 as never)).toThrow(TypeError);
|
||||
expect(() => JSONToLangNode(true as never)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip langNodeToJSON → JSONToLangNode', () => {
|
||||
it('preserves LangRecord', () => {
|
||||
const original: LangNode = { en: 'Hello' };
|
||||
const json = langNodeToJSON(original);
|
||||
const restored = JSONToLangNode(json) as { en: string };
|
||||
expect(restored.en).toBe('Hello');
|
||||
});
|
||||
|
||||
it('preserves plurals and keeps them callable', () => {
|
||||
const original = p({ en: { one: '1 item', other: '{{count}} items' } });
|
||||
const json = langNodeToJSON(original);
|
||||
const restored = JSONToLangNode(json) as unknown as (p: { count: number }) => Record<string, string>;
|
||||
expect(typeof restored).toBe('function');
|
||||
expect(restored({ count: 1 }).en).toBe('1 item');
|
||||
expect(restored({ count: 5 }).en).toBe('{{count}} items');
|
||||
});
|
||||
|
||||
it('preserves a full tree', () => {
|
||||
const node: LangNode = {
|
||||
common: { ok: { es: 'Aceptar', en: 'OK' } },
|
||||
items: p({ en: { one: '1 item', other: '{{count}} items' } })
|
||||
};
|
||||
const json = langNodeToJSON(node);
|
||||
const restored = JSONToLangNode(json) as LangBranch;
|
||||
const common = restored.common as LangBranch;
|
||||
expect((common.ok as Record<string, string>).en).toBe('OK');
|
||||
expect(typeof restored.items).toBe('function');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,120 @@
|
||||
import type { ID_PREFIX } from './consts.ts';
|
||||
import type { PluralCategory } from './plural_rules.ts';
|
||||
|
||||
export type SupportedLocale = 'es' | 'en' | 'ar' | 'de' | 'fr' | 'it' | 'pt' | 'ca' | 'eu' | 'gl';
|
||||
|
||||
export type LangRef = `${typeof ID_PREFIX}${string}`;
|
||||
|
||||
export type LangRecord = {
|
||||
[K in SupportedLocale]?: string;
|
||||
};
|
||||
|
||||
export type LangParams = Record<string, unknown>;
|
||||
|
||||
// NOTE: default is `any` (not `LangParams`) to preserve contravariant
|
||||
// parameter variance: a schema `(params: { name: string }) => ...` must be
|
||||
// assignable to the default type without forcing the user to widen their
|
||||
// signature. Public type-safety lives in `t()` / `ts()`, which infer the
|
||||
// concrete `P` via `ParamsFor<TType>`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LangFn<P = any> = (params: P) => LangRecord;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LangPluralFn<P = any> = (params: { count: number } & P) => LangRecord;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LangValue<P = any> = LangRecord | LangFn<P> | LangPluralFn<P> | LangRef;
|
||||
|
||||
export type LangBranch = { [key: string]: LangNode };
|
||||
|
||||
export type LangNode = LangValue | LangBranch;
|
||||
|
||||
export type LangString = string | LangRecord | LangRef;
|
||||
|
||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
export type LeafPaths<T, D extends number = 6> = [D] extends [never]
|
||||
? never
|
||||
: T extends LangValue
|
||||
? ''
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof T & string]: T[K] extends LangValue
|
||||
? K
|
||||
: `${K}.${LeafPaths<T[K], Prev[D]> & string}`;
|
||||
}[keyof T & string]
|
||||
: never;
|
||||
|
||||
export type GetTypeAtPath<Root, Current, P extends string> = P extends `${infer K}.${infer Rest}`
|
||||
? K extends keyof Current
|
||||
? GetTypeAtPath<Root, Current[K], Rest>
|
||||
: never
|
||||
: P extends keyof Current
|
||||
? Current[P] extends `#?${infer AliasPath}`
|
||||
? GetTypeAtPath<Root, Root, AliasPath>
|
||||
: Current[P]
|
||||
: never;
|
||||
|
||||
export type ParamsFor<T> = T extends (params: infer P) => LangRecord ? P : never;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type HasParams<T> = T extends (params: any) => LangRecord ? true : false;
|
||||
|
||||
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
|
||||
|
||||
export type PluralConfig = {
|
||||
[K in SupportedLocale]?: PluralForms;
|
||||
};
|
||||
|
||||
export type EngineLangInstance<S extends LangNode = LangNode> = {
|
||||
t: {
|
||||
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
|
||||
path: P,
|
||||
params: HasParams<TType> extends true ? ParamsFor<TType> : LangParams | undefined,
|
||||
locale: SupportedLocale
|
||||
): string;
|
||||
(path: string, params: LangParams | undefined, locale: SupportedLocale): string;
|
||||
};
|
||||
|
||||
ts: (value: LangString, locale: SupportedLocale) => string;
|
||||
|
||||
onSchemaChange: (fn: () => void) => () => void;
|
||||
extend: (namespace: string, module: LangNode) => void;
|
||||
register: <NS extends string, M extends LangNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
) => EngineLangInstance<S & { [K in NS]: M }>;
|
||||
setLogger: (logger: LangLogger) => void;
|
||||
getDefaultLocale: () => SupportedLocale;
|
||||
getFallbackChain: () => SupportedLocale[] | undefined;
|
||||
};
|
||||
|
||||
export type ActiveLangInstance<S extends LangNode = LangNode> = {
|
||||
t: {
|
||||
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
|
||||
path: P,
|
||||
params?: HasParams<TType> extends true ? ParamsFor<TType> : LangParams,
|
||||
locale?: SupportedLocale
|
||||
): string;
|
||||
(path: string, params?: LangParams, locale?: SupportedLocale): string;
|
||||
};
|
||||
|
||||
ts: (value: LangString, locale?: SupportedLocale) => string;
|
||||
|
||||
getLocale: () => SupportedLocale;
|
||||
setLocale: (locale: SupportedLocale) => void;
|
||||
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
|
||||
onSchemaChange: (fn: () => void) => () => void;
|
||||
extend: (namespace: string, module: LangNode) => void;
|
||||
register: <NS extends string, M extends LangNode>(
|
||||
namespace: NS,
|
||||
module: M
|
||||
) => ActiveLangInstance<S & { [K in NS]: M }>;
|
||||
setLogger: (logger: LangLogger) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export interface LangLogger {
|
||||
warn: (category: string, message: string) => void;
|
||||
error: (category: string, message: string) => void;
|
||||
}
|
||||
@ -0,0 +1,445 @@
|
||||
# logr
|
||||
|
||||
Professional structured logger. **Zero external dependencies.** Decoupled from
|
||||
i18n. Dynamic transports with per-sink filtering. Async-aware dispatch.
|
||||
Failure routing with `deniedFor`. Built-in adapters for Sentry, Datadog,
|
||||
Logtail (Better Stack), Grafana Loki and OpenTelemetry.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **6 severity levels** aligned with pino / Log4j / OpenTelemetry / Sentry:
|
||||
`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`.
|
||||
- **Structured entries** — category, context, tags, traceId, error, durationMs,
|
||||
source (auto-captured from stack in DEV).
|
||||
- **Lazy messages** — pass a thunk, evaluated only if the entry passes the
|
||||
global level filter.
|
||||
- **globalContext** merged into every entry (appVersion, env, sessionId, ...).
|
||||
- **`child(ctx)`** — logger with extended context; shares history and transports.
|
||||
- **`time(label)` / `timeEnd(label)`** — duration measurement with `timer` tag.
|
||||
- **Dynamic transports** — `addTransport()`, `subscribe()`, `removeAllTransports()`.
|
||||
- **Per-transport filters** — `minLevel` and `filter` at the transport level.
|
||||
- **Hybrid dispatch** — sync loop, transports may return `Promise<void>` for
|
||||
async work. Caller never awaits.
|
||||
- **Failure routing** — when a transport throws (sync) or rejects (async), a
|
||||
synthetic ERROR entry is dispatched to the remaining transports with
|
||||
`deniedFor: [failedName]`. Guarantees no cascade loop.
|
||||
- **Built-in adapters** — `sentryTransport`, `datadogTransport`,
|
||||
`logtailTransport`, `lokiTransport`, `otelTransport`. All accept the SDK as
|
||||
an injected parameter so logr stays dep-free.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
logr/
|
||||
├── index.ts Barrel exports
|
||||
├── types.ts LogLevel, LogEntry, LogInput, Transport, EngineLogger, ...
|
||||
├── engine-logger.ts Factory createEngineLogger()
|
||||
├── transports.ts consoleTransport, httpTransport, callbackTransport
|
||||
├── source.ts captureSource(), extractError()
|
||||
├── consts.ts runtime identifier (`engine_logger`)
|
||||
├── adapters/
|
||||
│ ├── sentry.ts sentryTransport(sentrySDK, options)
|
||||
│ ├── datadog.ts datadogTransport(ddLogger, options)
|
||||
│ ├── logtail.ts logtailTransport(logtailInstance, options)
|
||||
│ ├── loki.ts lokiTransport({ url, labels, ... })
|
||||
│ └── otel.ts otelTransport(otelLogger, options)
|
||||
└── test/
|
||||
└── logr.test.ts Unit tests (vitest, node env)
|
||||
```
|
||||
|
||||
## Alias
|
||||
|
||||
Configured in `svelte.config.js`:
|
||||
|
||||
```js
|
||||
alias: { $logr: 'src/arts/logr' }
|
||||
```
|
||||
|
||||
Imports:
|
||||
|
||||
```ts
|
||||
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
|
||||
import { sentryTransport } from '$logr/adapters/sentry';
|
||||
import { datadogTransport } from '$logr/adapters/datadog';
|
||||
import { logtailTransport } from '$logr/adapters/logtail';
|
||||
import { lokiTransport } from '$logr/adapters/loki';
|
||||
import { otelTransport } from '$logr/adapters/otel';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createEngineLogger, LogLevel, consoleTransport } from '$logr';
|
||||
|
||||
const logger = createEngineLogger({
|
||||
level: LogLevel.DEBUG,
|
||||
globalContext: { appVersion: '1.2.3', env: 'prod' },
|
||||
transports: [consoleTransport()]
|
||||
});
|
||||
|
||||
logger.info('auth', 'Login succeeded', {
|
||||
context: { userId: 42 },
|
||||
tags: ['auth', 'security'],
|
||||
traceId: 'req-abc-123'
|
||||
});
|
||||
|
||||
try { ... } catch (err) {
|
||||
logger.error('db', 'Query failed', { error: err, traceId: 'req-abc-123' });
|
||||
}
|
||||
|
||||
// Duration measurement
|
||||
logger.time('fetch-user');
|
||||
await fetchUser();
|
||||
logger.timeEnd('fetch-user', 'perf'); // emits INFO with durationMs and tag 'timer'
|
||||
|
||||
// Lazy message — only evaluated if the entry passes the level filter
|
||||
logger.debug('sql', () => `Query: ${expensiveFormat(query)}`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log levels
|
||||
|
||||
```ts
|
||||
enum LogLevel {
|
||||
TRACE = 0,
|
||||
DEBUG = 1,
|
||||
INFO = 2,
|
||||
WARN = 3,
|
||||
ERROR = 4,
|
||||
FATAL = 5,
|
||||
NONE = 999 // disables all logs
|
||||
}
|
||||
```
|
||||
|
||||
| Level | Intended use |
|
||||
| ----- | --------------------------------------------------------------------- |
|
||||
| TRACE | Very verbose tracing (spans, frame-by-frame) |
|
||||
| DEBUG | Development diagnostics |
|
||||
| INFO | Normal flow events worth noting |
|
||||
| WARN | Unexpected but non-interrupting situations |
|
||||
| ERROR | Recoverable errors that need attention |
|
||||
| FATAL | Unrecoverable failures (route to immediate paging) |
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `createEngineLogger(options)`
|
||||
|
||||
```ts
|
||||
interface LoggerOptions {
|
||||
level?: LogLevel; // @default LogLevel.WARN
|
||||
maxLogs?: number; // @default 1000
|
||||
transports?: Transport[]; // @default [consoleTransport()]
|
||||
globalContext?: Record<string, unknown>;
|
||||
captureSource?: boolean; // @default true in DEV, false in PROD
|
||||
}
|
||||
```
|
||||
|
||||
### Level methods
|
||||
|
||||
```ts
|
||||
logger.trace(category, message, input?)
|
||||
logger.debug(category, message, input?)
|
||||
logger.info (category, message, input?)
|
||||
logger.warn (category, message, input?)
|
||||
logger.error(category, message, input?)
|
||||
logger.fatal(category, message, input?)
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
```ts
|
||||
type LogMessage = string | (() => string); // lazy thunks supported
|
||||
|
||||
interface LogInput {
|
||||
context?: Record<string, unknown>; // merged with globalContext
|
||||
error?: unknown; // extracted to LogError { name, message, stack, cause }
|
||||
tags?: string[]; // finer-grained filtering than category
|
||||
traceId?: string; // correlate across async flows
|
||||
durationMs?: number; // auto-set by timeEnd()
|
||||
source?: LogSource; // manual override of auto-captured file:line
|
||||
}
|
||||
```
|
||||
|
||||
### History
|
||||
|
||||
```ts
|
||||
logger.getLogs(filters?) // defensive copy, filtered by level/minLevel/category/tag/traceId/since
|
||||
logger.clear()
|
||||
logger.serialize() // JSON string (circular-safe)
|
||||
```
|
||||
|
||||
### Runtime controls
|
||||
|
||||
```ts
|
||||
logger.setLevel(LogLevel.WARN)
|
||||
logger.setMaxLogs(500)
|
||||
logger.setGlobalContext({ appVersion: '1.3.0' })
|
||||
```
|
||||
|
||||
### Dynamic transports
|
||||
|
||||
```ts
|
||||
const remove = logger.addTransport(transport); // returns a detach fn
|
||||
const unsub = logger.subscribe((entry) => ...); // sugar over addTransport
|
||||
logger.removeAllTransports();
|
||||
logger.transports(); // read-only snapshot
|
||||
```
|
||||
|
||||
### Child loggers
|
||||
|
||||
```ts
|
||||
const reqLog = logger.child({ requestId: 'req-1', userId: 42 });
|
||||
reqLog.info('auth', 'authenticated'); // context merges parent + child
|
||||
```
|
||||
|
||||
Children **share** history, transports and level with the parent. They only
|
||||
extend `globalContext`.
|
||||
|
||||
### Timers
|
||||
|
||||
```ts
|
||||
logger.time('fetch');
|
||||
await doWork();
|
||||
logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transport interface
|
||||
|
||||
```ts
|
||||
interface Transport {
|
||||
write(entry: LogEntry): void | Promise<void>;
|
||||
minLevel?: LogLevel;
|
||||
filter?(entry: LogEntry): boolean;
|
||||
name?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- `minLevel` and `filter` are applied **after** the logger's global level
|
||||
filter, for fine-grained per-sink routing.
|
||||
- `name` appears in introspection and in `deniedFor` on failure entries.
|
||||
- Sync throws and promise rejections both route to the failure handler.
|
||||
|
||||
---
|
||||
|
||||
## Failure routing (`deniedFor`)
|
||||
|
||||
If `transport.write()` throws or rejects:
|
||||
|
||||
1. A synthetic `ERROR` entry is created with:
|
||||
- `category: 'engine_logger'`
|
||||
- `message: 'Transport "X" failed: ...'`
|
||||
- `error`: structured `LogError` from the thrown value
|
||||
- `tags: ['engine_logger', 'transport-failure']`
|
||||
- `deniedFor: ['X']`
|
||||
2. The failure entry is dispatched to every **other** transport, skipping the
|
||||
failed one by reference.
|
||||
3. If a second transport throws while handling the failure entry, the
|
||||
secondary failure is **swallowed** (console-only) — this guard guarantees
|
||||
no cascade loop.
|
||||
|
||||
This is why you can attach Sentry (or any other transport) safely: if Sentry
|
||||
is down, the failure gets logged to console/Datadog/Loki/etc., but never
|
||||
back to Sentry.
|
||||
|
||||
---
|
||||
|
||||
## Built-in transports
|
||||
|
||||
### `consoleTransport(options?)`
|
||||
|
||||
```ts
|
||||
interface ConsoleTransportOptions {
|
||||
timestamp?: boolean; // @default true
|
||||
prefix?: boolean; // @default true
|
||||
source?: boolean; // @default true — shows "(file.ts:42)"
|
||||
minLevel?: LogLevel;
|
||||
}
|
||||
```
|
||||
|
||||
Browser/Node console with the appropriate method per level (TRACE → debug,
|
||||
FATAL → error).
|
||||
|
||||
### `httpTransport({ url, headers?, minLevel? })`
|
||||
|
||||
Fire-and-forget POST to an arbitrary endpoint. No retry/batching built in —
|
||||
use it for prototypes or wrap it for production.
|
||||
|
||||
### `callbackTransport(fn, options?)`
|
||||
|
||||
Factory that builds a `Transport` out of a callback. Structurally equivalent
|
||||
to `logger.subscribe(fn)` but lets you pre-register it in
|
||||
`LoggerOptions.transports` and configure `minLevel`, `filter`, `name`.
|
||||
|
||||
---
|
||||
|
||||
## Adapters
|
||||
|
||||
All adapters follow the same pattern: **receive the third-party SDK as an
|
||||
injected parameter**. logr itself never imports `@sentry/*`, `@datadog/*`,
|
||||
etc. — callers install those packages and pass the already-initialized SDK.
|
||||
This keeps logr zero-dep and makes tests trivial (mock the SDK with a plain
|
||||
object).
|
||||
|
||||
### Sentry — `sentryTransport(SentrySDK, options?)`
|
||||
|
||||
```ts
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { sentryTransport } from '$logr/adapters/sentry';
|
||||
|
||||
Sentry.init({ dsn: '<your-dsn>', environment: 'prod' });
|
||||
|
||||
logger.addTransport(sentryTransport(Sentry, {
|
||||
minLevel: LogLevel.ERROR, // @default ERROR
|
||||
breadcrumbLevel: LogLevel.INFO // @default INFO
|
||||
}));
|
||||
```
|
||||
|
||||
- `ERROR+` → `captureException(entry.error)` if present, otherwise
|
||||
`captureMessage(entry.message, severity)`.
|
||||
- `[breadcrumbLevel, minLevel)` → `addBreadcrumb(...)` attached to the next
|
||||
captured event.
|
||||
- `< breadcrumbLevel` → ignored.
|
||||
- Every event passes through `Sentry.withScope()` to attach tags
|
||||
(`category`, `traceId`, `tag:x`), contexts (`log.context`, `log.source`,
|
||||
`log.timing`) without polluting the global scope.
|
||||
|
||||
### Datadog — `datadogTransport(ddLogger, options?)`
|
||||
|
||||
```ts
|
||||
import { datadogLogs } from '@datadog/browser-logs';
|
||||
import { datadogTransport } from '$logr/adapters/datadog';
|
||||
|
||||
datadogLogs.init({
|
||||
clientToken: 'pubXXXXXXXXXX',
|
||||
site: 'datadoghq.eu',
|
||||
service: 'my-app',
|
||||
env: 'prod',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN }));
|
||||
```
|
||||
|
||||
Level mapping: TRACE/DEBUG → `debug`, INFO → `info`, WARN → `warn`,
|
||||
ERROR/FATAL → `error`. Original level preserved in `messageContext.logLevel`.
|
||||
Uses reserved-safe names (`logTags`, `sourceLocation`, `durationMs`) to avoid
|
||||
Datadog's reserved attributes (`source`, `duration`, `tags`).
|
||||
|
||||
### Logtail (Better Stack) — `logtailTransport(logtailInstance, options?)`
|
||||
|
||||
```ts
|
||||
import { Logtail } from '@logtail/browser';
|
||||
import { logtailTransport } from '$logr/adapters/logtail';
|
||||
|
||||
const lt = new Logtail('<source-token>');
|
||||
logger.addTransport(logtailTransport(lt, { minLevel: LogLevel.INFO }));
|
||||
```
|
||||
|
||||
### Grafana Loki — `lokiTransport(options)`
|
||||
|
||||
No SDK required — pure HTTP POST to the Loki push endpoint.
|
||||
|
||||
```ts
|
||||
import { lokiTransport } from '$logr/adapters/loki';
|
||||
|
||||
logger.addTransport(lokiTransport({
|
||||
url: 'https://logs-prod-006.grafana.net/loki/api/v1/push',
|
||||
labels: { app: 'my-app', env: 'prod' }, // low-cardinality only!
|
||||
basicAuthUser: '123456',
|
||||
basicAuthPassword: 'eyJhbG...',
|
||||
tenantId: 'my-tenant' // X-Scope-OrgID header
|
||||
}));
|
||||
```
|
||||
|
||||
The **level** goes into the stream labels (low cardinality, safe). Everything
|
||||
else (context, tags, traceId, error) is JSON-encoded in the log line so you
|
||||
can query with LogQL:
|
||||
`{app="my-app"} | json | traceId="req-1"`.
|
||||
|
||||
### OpenTelemetry — `otelTransport(otelLogger, options?)`
|
||||
|
||||
```ts
|
||||
import { logs } from '@opentelemetry/api-logs';
|
||||
import { LoggerProvider } from '@opentelemetry/sdk-logs';
|
||||
import { otelTransport } from '$logr/adapters/otel';
|
||||
|
||||
const provider = new LoggerProvider({ /* ... */ });
|
||||
logs.setGlobalLoggerProvider(provider);
|
||||
const otelLogger = logs.getLogger('my-app');
|
||||
|
||||
logger.addTransport(otelTransport(otelLogger));
|
||||
```
|
||||
|
||||
Maps `LogLevel` to OTel severityNumber: TRACE=1, DEBUG=5, INFO=9, WARN=13,
|
||||
ERROR=17, FATAL=21 (base of each 4-number band per the OTel spec).
|
||||
Structured context, tags, source and error flatten into OTel attributes
|
||||
using dotted keys (`log.context.userId`, `code.filepath`, `exception.type`,
|
||||
etc.).
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Sentry + breadcrumbs + metrics
|
||||
|
||||
```ts
|
||||
// Events in Sentry (ERROR+) and breadcrumbs (INFO/WARN)
|
||||
logger.addTransport(sentryTransport(Sentry));
|
||||
|
||||
// Counter of errors
|
||||
logger.subscribe((entry) => {
|
||||
if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc();
|
||||
});
|
||||
```
|
||||
|
||||
### Scoped child for a request
|
||||
|
||||
```ts
|
||||
export async function handle(request: Request) {
|
||||
const reqLog = logger.child({
|
||||
requestId: crypto.randomUUID(),
|
||||
path: new URL(request.url).pathname
|
||||
});
|
||||
reqLog.info('http', 'incoming request');
|
||||
try {
|
||||
return await handler(request, reqLog);
|
||||
} catch (err) {
|
||||
reqLog.error('http', 'handler threw', { error: err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Feature-flagged transport
|
||||
|
||||
```ts
|
||||
if (flags.enableRemoteLogs) {
|
||||
const detach = logger.addTransport(httpTransport({ url: '...' }));
|
||||
onFlagDisable(() => detach());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npx vitest run --project server
|
||||
```
|
||||
|
||||
Covers 171 cases: level filtering, lazy messages, globalContext merging,
|
||||
error extraction, tags/traceId, time/timeEnd, child semantics, getLogs
|
||||
filters, dynamic add/remove, per-transport minLevel/filter, sync and async
|
||||
failure routing with `deniedFor`, cascade guard, serialize, plus adapter
|
||||
unit tests (Sentry, Datadog, Logtail, Loki, OpenTelemetry) using mocked SDKs.
|
||||
@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Adapter test suite — sentry, datadog, logtail, loki, otel.
|
||||
*
|
||||
* All adapters use duck-typed SDK injection so the tests mock the SDK with
|
||||
* plain objects/spies. No real network calls are made.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LogLevel, createEngineLogger, type LogEntry } from '../index.ts';
|
||||
import { sentryTransport, type SentryLike, type SentryScopeLike } from './sentry.ts';
|
||||
import { datadogTransport, type DatadogLoggerLike } from './datadog.ts';
|
||||
import { logtailTransport, type LogtailLike } from './logtail.ts';
|
||||
import { lokiTransport } from './loki.ts';
|
||||
import { otelTransport, type OtelLoggerLike, type OtelLogRecord } from './otel.ts';
|
||||
|
||||
// ============================================================================
|
||||
// SENTRY
|
||||
// ============================================================================
|
||||
|
||||
function mockSentry(): {
|
||||
sentry: SentryLike;
|
||||
calls: {
|
||||
captureException: unknown[];
|
||||
captureMessage: { msg: string; level?: string }[];
|
||||
breadcrumbs: { message?: string; level?: string; category?: string }[];
|
||||
tags: Record<string, string>;
|
||||
contexts: Record<string, Record<string, unknown> | null>;
|
||||
};
|
||||
} {
|
||||
const calls = {
|
||||
captureException: [] as unknown[],
|
||||
captureMessage: [] as { msg: string; level?: string }[],
|
||||
breadcrumbs: [] as { message?: string; level?: string; category?: string }[],
|
||||
tags: {} as Record<string, string>,
|
||||
contexts: {} as Record<string, Record<string, unknown> | null>
|
||||
};
|
||||
|
||||
const scope: SentryScopeLike = {
|
||||
setTag(key, value) {
|
||||
calls.tags[key] = value;
|
||||
},
|
||||
setContext(name, context) {
|
||||
calls.contexts[name] = context;
|
||||
}
|
||||
};
|
||||
|
||||
const sentry: SentryLike = {
|
||||
captureException(err) {
|
||||
calls.captureException.push(err);
|
||||
},
|
||||
captureMessage(msg, level) {
|
||||
calls.captureMessage.push({ msg, level });
|
||||
},
|
||||
addBreadcrumb(bc) {
|
||||
calls.breadcrumbs.push(bc);
|
||||
},
|
||||
withScope(cb) {
|
||||
cb(scope);
|
||||
}
|
||||
};
|
||||
|
||||
return { sentry, calls };
|
||||
}
|
||||
|
||||
describe('sentryTransport', () => {
|
||||
it('routes INFO to breadcrumb', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
log.info('auth', 'Login attempt');
|
||||
|
||||
expect(calls.breadcrumbs).toHaveLength(1);
|
||||
expect(calls.breadcrumbs[0].message).toBe('Login attempt');
|
||||
expect(calls.breadcrumbs[0].level).toBe('info');
|
||||
expect(calls.captureMessage).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes ERROR without error to captureMessage', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
log.error('auth', 'Login failed');
|
||||
|
||||
expect(calls.captureMessage).toHaveLength(1);
|
||||
expect(calls.captureMessage[0]).toEqual({ msg: 'Login failed', level: 'error' });
|
||||
});
|
||||
|
||||
it('routes ERROR with error to captureException', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
const err = new Error('DB down');
|
||||
log.error('db', 'Query failed', { error: err });
|
||||
|
||||
expect(calls.captureException).toHaveLength(1);
|
||||
const captured = calls.captureException[0] as Error;
|
||||
expect(captured.message).toBe('DB down');
|
||||
expect(captured.name).toBe('Error');
|
||||
});
|
||||
|
||||
it('routes FATAL to captureMessage with "fatal" severity', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
log.fatal('kernel', 'Unrecoverable');
|
||||
expect(calls.captureMessage[0].level).toBe('fatal');
|
||||
});
|
||||
|
||||
it('attaches category/traceId/tags as scope tags', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
log.error('auth', 'fail', { traceId: 'req-1', tags: ['login', 'oauth'] });
|
||||
|
||||
expect(calls.tags.category).toBe('auth');
|
||||
expect(calls.tags.traceId).toBe('req-1');
|
||||
expect(calls.tags['tag:login']).toBe('true');
|
||||
expect(calls.tags['tag:oauth']).toBe('true');
|
||||
});
|
||||
|
||||
it('attaches context and source as scope contexts', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
globalContext: { appVersion: '1.0' },
|
||||
transports: []
|
||||
});
|
||||
log.addTransport(sentryTransport(sentry));
|
||||
log.error('t', 'fail', { context: { userId: 42 } });
|
||||
|
||||
expect(calls.contexts['log.context']).toEqual({ appVersion: '1.0', userId: 42 });
|
||||
});
|
||||
|
||||
it('does not emit below breadcrumbLevel', () => {
|
||||
const { sentry, calls } = mockSentry();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(sentryTransport(sentry, { breadcrumbLevel: LogLevel.INFO }));
|
||||
log.debug('t', 'skipped');
|
||||
log.trace('t', 'skipped');
|
||||
expect(calls.breadcrumbs).toHaveLength(0);
|
||||
expect(calls.captureMessage).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DATADOG
|
||||
// ============================================================================
|
||||
|
||||
function mockDatadog(): {
|
||||
dd: DatadogLoggerLike;
|
||||
calls: { level: string; message: string; context: unknown; error?: Error }[];
|
||||
} {
|
||||
const calls: { level: string; message: string; context: unknown; error?: Error }[] = [];
|
||||
const dd: DatadogLoggerLike = {
|
||||
debug(msg, ctx) {
|
||||
calls.push({ level: 'debug', message: msg, context: ctx });
|
||||
},
|
||||
info(msg, ctx) {
|
||||
calls.push({ level: 'info', message: msg, context: ctx });
|
||||
},
|
||||
warn(msg, ctx) {
|
||||
calls.push({ level: 'warn', message: msg, context: ctx });
|
||||
},
|
||||
error(msg, ctx, err) {
|
||||
calls.push({ level: 'error', message: msg, context: ctx, error: err });
|
||||
}
|
||||
};
|
||||
return { dd, calls };
|
||||
}
|
||||
|
||||
describe('datadogTransport', () => {
|
||||
it('maps TRACE/DEBUG to debug, FATAL/ERROR to error', () => {
|
||||
const { dd, calls } = mockDatadog();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
||||
|
||||
log.trace('t', 'a');
|
||||
log.debug('t', 'b');
|
||||
log.info('t', 'c');
|
||||
log.warn('t', 'd');
|
||||
log.error('t', 'e');
|
||||
log.fatal('t', 'f');
|
||||
|
||||
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
|
||||
});
|
||||
|
||||
it('preserves original level in messageContext.logLevel', () => {
|
||||
const { dd, calls } = mockDatadog();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
||||
log.fatal('t', 'boom');
|
||||
const ctx = calls[0].context as Record<string, unknown>;
|
||||
expect(ctx.logLevel).toBe('FATAL');
|
||||
});
|
||||
|
||||
it('uses reserved-safe names (logTags, sourceLocation, durationMs)', () => {
|
||||
const { dd, calls } = mockDatadog();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
||||
log.info('t', 'hi', {
|
||||
tags: ['x'],
|
||||
traceId: 'r1',
|
||||
durationMs: 42
|
||||
});
|
||||
|
||||
const ctx = calls[0].context as Record<string, unknown>;
|
||||
expect(ctx.logTags).toEqual(['x']);
|
||||
expect(ctx.traceId).toBe('r1');
|
||||
expect(ctx.durationMs).toBe(42);
|
||||
});
|
||||
|
||||
it('passes reconstructed Error to dd.error', () => {
|
||||
const { dd, calls } = mockDatadog();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
||||
log.error('t', 'fail', { error: new Error('boom') });
|
||||
|
||||
expect(calls[0].error).toBeInstanceOf(Error);
|
||||
expect(calls[0].error?.message).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LOGTAIL
|
||||
// ============================================================================
|
||||
|
||||
function mockLogtail(): {
|
||||
lt: LogtailLike;
|
||||
calls: { level: string; message: string; context: unknown }[];
|
||||
} {
|
||||
const calls: { level: string; message: string; context: unknown }[] = [];
|
||||
const lt: LogtailLike = {
|
||||
debug(msg, ctx) {
|
||||
calls.push({ level: 'debug', message: msg, context: ctx });
|
||||
},
|
||||
info(msg, ctx) {
|
||||
calls.push({ level: 'info', message: msg, context: ctx });
|
||||
},
|
||||
warn(msg, ctx) {
|
||||
calls.push({ level: 'warn', message: msg, context: ctx });
|
||||
},
|
||||
error(msg, ctx) {
|
||||
calls.push({ level: 'error', message: msg, context: ctx });
|
||||
}
|
||||
};
|
||||
return { lt, calls };
|
||||
}
|
||||
|
||||
describe('logtailTransport', () => {
|
||||
it('maps levels correctly', () => {
|
||||
const { lt, calls } = mockLogtail();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
|
||||
|
||||
log.trace('t', 'a');
|
||||
log.debug('t', 'b');
|
||||
log.info('t', 'c');
|
||||
log.warn('t', 'd');
|
||||
log.error('t', 'e');
|
||||
log.fatal('t', 'f');
|
||||
|
||||
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
|
||||
});
|
||||
|
||||
it('includes logLevel, category and error in context', () => {
|
||||
const { lt, calls } = mockLogtail();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
|
||||
log.error('auth', 'fail', { error: new Error('boom') });
|
||||
|
||||
const ctx = calls[0].context as Record<string, unknown>;
|
||||
expect(ctx.category).toBe('auth');
|
||||
expect(ctx.logLevel).toBe('ERROR');
|
||||
expect((ctx.error as { message: string }).message).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LOKI
|
||||
// ============================================================================
|
||||
|
||||
describe('lokiTransport', () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
fetchMock = vi.fn(async () => new Response('', { status: 204 }));
|
||||
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('POSTs to /loki/api/v1/push with stream labels and JSON line', async () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(
|
||||
lokiTransport({
|
||||
url: 'https://loki.example.com/loki/api/v1/push',
|
||||
labels: { app: 'test' },
|
||||
minLevel: LogLevel.TRACE
|
||||
})
|
||||
);
|
||||
|
||||
log.info('auth', 'hi');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://loki.example.com/loki/api/v1/push');
|
||||
expect(init.method).toBe('POST');
|
||||
|
||||
const body = JSON.parse(init.body as string) as {
|
||||
streams: { stream: Record<string, string>; values: [string, string][] }[];
|
||||
};
|
||||
expect(body.streams[0].stream).toEqual({ app: 'test', level: 'info', category: 'auth' });
|
||||
const line = JSON.parse(body.streams[0].values[0][1]) as { message: string; level: string };
|
||||
expect(line.message).toBe('hi');
|
||||
expect(line.level).toBe('INFO');
|
||||
});
|
||||
|
||||
it('builds basic auth header from user/password', async () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(
|
||||
lokiTransport({
|
||||
url: 'https://loki.example.com/push',
|
||||
basicAuthUser: 'user',
|
||||
basicAuthPassword: 'pass',
|
||||
minLevel: LogLevel.TRACE
|
||||
})
|
||||
);
|
||||
|
||||
log.info('t', 'x');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const expected = `Basic ${btoa('user:pass')}`;
|
||||
expect(headers.Authorization).toBe(expected);
|
||||
});
|
||||
|
||||
it('sends X-Scope-OrgID header when tenantId is provided', async () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(
|
||||
lokiTransport({
|
||||
url: 'https://loki.example.com/push',
|
||||
tenantId: 'tenant-1',
|
||||
minLevel: LogLevel.TRACE
|
||||
})
|
||||
);
|
||||
|
||||
log.info('t', 'x');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['X-Scope-OrgID']).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('rejects on non-2xx response (routed to deniedFor)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('bad', { status: 500 }));
|
||||
|
||||
const seen: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({ name: 'capture', write: (e) => seen.push(e) });
|
||||
log.addTransport(
|
||||
lokiTransport({ url: 'https://loki.example.com/push', minLevel: LogLevel.TRACE })
|
||||
);
|
||||
|
||||
log.info('t', 'hi');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const failure = seen.find((e) => e.deniedFor !== undefined);
|
||||
expect(failure).toBeDefined();
|
||||
expect(failure?.deniedFor).toEqual(['loki']);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// OPENTELEMETRY
|
||||
// ============================================================================
|
||||
|
||||
function mockOtelLogger(): {
|
||||
otelLogger: OtelLoggerLike;
|
||||
emitted: OtelLogRecord[];
|
||||
} {
|
||||
const emitted: OtelLogRecord[] = [];
|
||||
const otelLogger: OtelLoggerLike = {
|
||||
emit(record) {
|
||||
emitted.push(record);
|
||||
}
|
||||
};
|
||||
return { otelLogger, emitted };
|
||||
}
|
||||
|
||||
describe('otelTransport', () => {
|
||||
it('maps levels to OTel severityNumber (1/5/9/13/17/21)', () => {
|
||||
const { otelLogger, emitted } = mockOtelLogger();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
||||
|
||||
log.trace('t', 'a');
|
||||
log.debug('t', 'b');
|
||||
log.info('t', 'c');
|
||||
log.warn('t', 'd');
|
||||
log.error('t', 'e');
|
||||
log.fatal('t', 'f');
|
||||
|
||||
expect(emitted.map((r) => r.severityNumber)).toEqual([1, 5, 9, 13, 17, 21]);
|
||||
});
|
||||
|
||||
it('sets severityText to the level name', () => {
|
||||
const { otelLogger, emitted } = mockOtelLogger();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
||||
log.info('t', 'hi');
|
||||
expect(emitted[0].severityText).toBe('INFO');
|
||||
});
|
||||
|
||||
it('flattens context as log.context.* attributes', () => {
|
||||
const { otelLogger, emitted } = mockOtelLogger();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
||||
log.info('auth', 'hi', { context: { userId: 42, region: 'eu' } });
|
||||
|
||||
const attrs = emitted[0].attributes ?? {};
|
||||
expect(attrs['log.category']).toBe('auth');
|
||||
expect(attrs['log.context.userId']).toBe(42);
|
||||
expect(attrs['log.context.region']).toBe('eu');
|
||||
});
|
||||
|
||||
it('maps error fields to exception.* attributes', () => {
|
||||
const { otelLogger, emitted } = mockOtelLogger();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
||||
log.error('t', 'fail', { error: new Error('boom') });
|
||||
|
||||
const attrs = emitted[0].attributes ?? {};
|
||||
expect(attrs['exception.type']).toBe('Error');
|
||||
expect(attrs['exception.message']).toBe('boom');
|
||||
expect(attrs['exception.stacktrace']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,100 @@
|
||||
import type { LogEntry, Transport } from '../types.ts';
|
||||
import { LogLevel } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Duck-typed subset of the `@datadog/browser-logs` SDK used by the transport.
|
||||
* The caller injects the already-initialized SDK (normally `datadogLogs.logger`).
|
||||
*
|
||||
* @example
|
||||
* import { datadogLogs } from '@datadog/browser-logs';
|
||||
* datadogLogs.init({ clientToken: '...', site: 'datadoghq.eu' });
|
||||
* logger.addTransport(datadogTransport(datadogLogs.logger));
|
||||
*/
|
||||
export interface DatadogLoggerLike {
|
||||
debug(message: string, messageContext?: Record<string, unknown>): void;
|
||||
info(message: string, messageContext?: Record<string, unknown>): void;
|
||||
warn(message: string, messageContext?: Record<string, unknown>): void;
|
||||
error(message: string, messageContext?: Record<string, unknown>, error?: Error): void;
|
||||
}
|
||||
|
||||
export interface DatadogTransportOptions {
|
||||
/** @default LogLevel.INFO — INFO/WARN/ERROR/FATAL reach the backend */
|
||||
minLevel?: LogLevel;
|
||||
/** @default 'datadog' */
|
||||
name?: string;
|
||||
filter?: (entry: LogEntry) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that forwards entries to the `@datadog/browser-logs` SDK.
|
||||
*
|
||||
* The Datadog SDK handles batching, retries and throttling internally. This
|
||||
* adapter maps levels to the SDK shortcuts and serializes the structured
|
||||
* context into `messageContext`. Level mapping:
|
||||
* - TRACE, DEBUG → `debug`
|
||||
* - INFO → `info`
|
||||
* - WARN → `warn`
|
||||
* - ERROR, FATAL → `error`
|
||||
*
|
||||
* The original level is preserved in `messageContext.logLevel` for
|
||||
* downstream routing/alerts.
|
||||
*
|
||||
* @example
|
||||
* import { datadogLogs } from '@datadog/browser-logs';
|
||||
* datadogLogs.init({
|
||||
* clientToken: 'pubXXXXXXXXXX',
|
||||
* site: 'datadoghq.eu',
|
||||
* service: 'my-app',
|
||||
* env: 'prod',
|
||||
* version: '1.0.0'
|
||||
* });
|
||||
*
|
||||
* logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN }));
|
||||
*/
|
||||
export function datadogTransport(
|
||||
dd: DatadogLoggerLike,
|
||||
options: DatadogTransportOptions = {}
|
||||
): Transport {
|
||||
return {
|
||||
name: options.name ?? 'datadog',
|
||||
minLevel: options.minLevel ?? LogLevel.INFO,
|
||||
filter: options.filter,
|
||||
write(entry: LogEntry): void {
|
||||
// `source`, `duration`, `tags` are Datadog-reserved attributes — we
|
||||
// use suffixed names to avoid collisions.
|
||||
const messageContext: Record<string, unknown> = {
|
||||
category: entry.category,
|
||||
logLevel: LogLevel[entry.level],
|
||||
...(entry.context ?? {})
|
||||
};
|
||||
if (entry.tags?.length) messageContext.logTags = entry.tags;
|
||||
if (entry.traceId) messageContext.traceId = entry.traceId;
|
||||
if (entry.durationMs !== undefined) messageContext.durationMs = entry.durationMs;
|
||||
if (entry.source) messageContext.sourceLocation = entry.source;
|
||||
|
||||
switch (entry.level) {
|
||||
case LogLevel.TRACE:
|
||||
case LogLevel.DEBUG:
|
||||
dd.debug(entry.message, messageContext);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
dd.info(entry.message, messageContext);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
dd.warn(entry.message, messageContext);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
case LogLevel.FATAL:
|
||||
if (entry.error) {
|
||||
const reconstructed = new Error(entry.error.message);
|
||||
reconstructed.name = entry.error.name;
|
||||
if (entry.error.stack) reconstructed.stack = entry.error.stack;
|
||||
dd.error(entry.message, messageContext, reconstructed);
|
||||
} else {
|
||||
dd.error(entry.message, messageContext);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { LogEntry, Transport } from '../types.ts';
|
||||
import { LogLevel } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Duck-typed subset of the `@logtail/browser` SDK (Better Stack).
|
||||
*
|
||||
* @example
|
||||
* import { Logtail } from '@logtail/browser';
|
||||
* const lt = new Logtail('source-token');
|
||||
* logger.addTransport(logtailTransport(lt));
|
||||
*/
|
||||
export interface LogtailLike {
|
||||
debug(message: string, context?: Record<string, unknown>): void;
|
||||
info(message: string, context?: Record<string, unknown>): void;
|
||||
warn(message: string, context?: Record<string, unknown>): void;
|
||||
error(message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface LogtailTransportOptions {
|
||||
/** @default LogLevel.INFO */
|
||||
minLevel?: LogLevel;
|
||||
/** @default 'logtail' */
|
||||
name?: string;
|
||||
filter?: (entry: LogEntry) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that forwards entries to Logtail (Better Stack).
|
||||
*
|
||||
* The Logtail SDK handles batching, retries and fingerprinting internally.
|
||||
* Level mapping:
|
||||
* - TRACE, DEBUG → `debug`
|
||||
* - INFO → `info`
|
||||
* - WARN → `warn`
|
||||
* - ERROR, FATAL → `error`
|
||||
*
|
||||
* The original level is preserved in `context.logLevel` for downstream routing.
|
||||
*/
|
||||
export function logtailTransport(
|
||||
lt: LogtailLike,
|
||||
options: LogtailTransportOptions = {}
|
||||
): Transport {
|
||||
return {
|
||||
name: options.name ?? 'logtail',
|
||||
minLevel: options.minLevel ?? LogLevel.INFO,
|
||||
filter: options.filter,
|
||||
write(entry: LogEntry): void {
|
||||
const context: Record<string, unknown> = {
|
||||
category: entry.category,
|
||||
logLevel: LogLevel[entry.level],
|
||||
...(entry.context ?? {})
|
||||
};
|
||||
if (entry.tags?.length) context.logTags = entry.tags;
|
||||
if (entry.traceId) context.traceId = entry.traceId;
|
||||
if (entry.durationMs !== undefined) context.durationMs = entry.durationMs;
|
||||
if (entry.source) context.sourceLocation = entry.source;
|
||||
if (entry.error) context.error = entry.error;
|
||||
|
||||
switch (entry.level) {
|
||||
case LogLevel.TRACE:
|
||||
case LogLevel.DEBUG:
|
||||
lt.debug(entry.message, context);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
lt.info(entry.message, context);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
lt.warn(entry.message, context);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
case LogLevel.FATAL:
|
||||
lt.error(entry.message, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import type { LogEntry, Transport } from '../types.ts';
|
||||
import { LogLevel } from '../types.ts';
|
||||
|
||||
export interface LokiTransportOptions {
|
||||
/** Absolute URL of the push endpoint. Typically `https://<host>/loki/api/v1/push`. */
|
||||
url: string;
|
||||
/**
|
||||
* Labels that identify the stream in Loki. Labels drive indexing and
|
||||
* filtering — keep their cardinality low (never user ids, trace ids, etc.;
|
||||
* those belong in the log line as JSON).
|
||||
*
|
||||
* @example { app: 'my-app', env: 'prod', source: 'browser' }
|
||||
*/
|
||||
labels?: Record<string, string>;
|
||||
/** Extra headers — useful for auth. */
|
||||
headers?: Record<string, string>;
|
||||
/** Basic auth user. Equivalent to `Authorization: Basic base64(user:pass)`. */
|
||||
basicAuthUser?: string;
|
||||
basicAuthPassword?: string;
|
||||
/** @default LogLevel.INFO */
|
||||
minLevel?: LogLevel;
|
||||
/** @default 'loki' */
|
||||
name?: string;
|
||||
filter?: (entry: LogEntry) => boolean;
|
||||
/**
|
||||
* Grafana Cloud / multi-tenant Loki tenant id. Sent as
|
||||
* `X-Scope-OrgID` header.
|
||||
*/
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that POSTs entries to the Grafana Loki push endpoint.
|
||||
*
|
||||
* - No SDK — plain `fetch` to `/loki/api/v1/push`.
|
||||
* - The level is attached as a `level` label in the stream (low cardinality,
|
||||
* safe).
|
||||
* - The rest of the structured context is serialized as JSON in the log line,
|
||||
* allowing LogQL queries like `{app="my-app"} | json | traceId="req-1"`.
|
||||
* - Fire-and-forget from the caller's perspective — network failures bubble
|
||||
* up through the promise, which the logger routes via `deniedFor`.
|
||||
*
|
||||
* @example
|
||||
* logger.addTransport(lokiTransport({
|
||||
* url: 'https://logs-prod-006.grafana.net/loki/api/v1/push',
|
||||
* labels: { app: 'my-app', env: 'prod' },
|
||||
* basicAuthUser: '123456',
|
||||
* basicAuthPassword: 'eyJhbG...',
|
||||
* tenantId: 'fake'
|
||||
* }));
|
||||
*/
|
||||
export function lokiTransport(options: LokiTransportOptions): Transport {
|
||||
const baseLabels = options.labels ?? {};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (options.basicAuthUser !== undefined) {
|
||||
const credentials = btoa(`${options.basicAuthUser}:${options.basicAuthPassword ?? ''}`);
|
||||
headers.Authorization = `Basic ${credentials}`;
|
||||
}
|
||||
if (options.tenantId) {
|
||||
headers['X-Scope-OrgID'] = options.tenantId;
|
||||
}
|
||||
|
||||
function serialize(entry: LogEntry): [string, string] {
|
||||
const line = JSON.stringify({
|
||||
id: entry.id,
|
||||
message: entry.message,
|
||||
level: LogLevel[entry.level],
|
||||
...(entry.context ?? {}),
|
||||
tags: entry.tags,
|
||||
traceId: entry.traceId,
|
||||
durationMs: entry.durationMs,
|
||||
sourceLocation: entry.source,
|
||||
error: entry.error
|
||||
});
|
||||
const ns = String(entry.timestamp.getTime()) + '000000';
|
||||
return [ns, line];
|
||||
}
|
||||
|
||||
async function push(streams: { stream: Record<string, string>; values: [string, string][] }[]): Promise<void> {
|
||||
const res = await fetch(options.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ streams })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Loki push failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
function streamKey(labels: Record<string, string>): string {
|
||||
// Stable JSON: keys sorted so identical label sets collapse into one stream.
|
||||
return JSON.stringify(labels, Object.keys(labels).sort());
|
||||
}
|
||||
|
||||
return {
|
||||
name: options.name ?? 'loki',
|
||||
minLevel: options.minLevel ?? LogLevel.INFO,
|
||||
filter: options.filter,
|
||||
async write(entry: LogEntry): Promise<void> {
|
||||
const streamLabels: Record<string, string> = {
|
||||
...baseLabels,
|
||||
level: levelLabel(entry.level),
|
||||
category: entry.category
|
||||
};
|
||||
await push([{ stream: streamLabels, values: [serialize(entry)] }]);
|
||||
},
|
||||
async writeBatch(entries: LogEntry[]): Promise<void> {
|
||||
// Group entries by their stream labels — Loki stores timeseries by
|
||||
// label set, so batching is cheapest when entries sharing labels
|
||||
// collapse into a single stream.
|
||||
const grouped = new Map<string, { stream: Record<string, string>; values: [string, string][] }>();
|
||||
for (const entry of entries) {
|
||||
const labels: Record<string, string> = {
|
||||
...baseLabels,
|
||||
level: levelLabel(entry.level),
|
||||
category: entry.category
|
||||
};
|
||||
const key = streamKey(labels);
|
||||
let bucket = grouped.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { stream: labels, values: [] };
|
||||
grouped.set(key, bucket);
|
||||
}
|
||||
bucket.values.push(serialize(entry));
|
||||
}
|
||||
await push([...grouped.values()]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function levelLabel(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.TRACE:
|
||||
return 'trace';
|
||||
case LogLevel.DEBUG:
|
||||
return 'debug';
|
||||
case LogLevel.INFO:
|
||||
return 'info';
|
||||
case LogLevel.WARN:
|
||||
return 'warn';
|
||||
case LogLevel.ERROR:
|
||||
return 'error';
|
||||
case LogLevel.FATAL:
|
||||
return 'fatal';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import type { LogEntry, Transport } from '../types.ts';
|
||||
import { LogLevel } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Duck-typed subset of the OpenTelemetry Logs API. The caller injects an
|
||||
* already-configured OTel `Logger` (usually obtained from a `LoggerProvider`).
|
||||
*
|
||||
* @example
|
||||
* import { logs } from '@opentelemetry/api-logs';
|
||||
* import { LoggerProvider } from '@opentelemetry/sdk-logs';
|
||||
* const provider = new LoggerProvider({ ... });
|
||||
* logs.setGlobalLoggerProvider(provider);
|
||||
* const otelLogger = logs.getLogger('my-app');
|
||||
* logger.addTransport(otelTransport(otelLogger));
|
||||
*/
|
||||
export interface OtelLoggerLike {
|
||||
emit(record: OtelLogRecord): void;
|
||||
}
|
||||
|
||||
export interface OtelLogRecord {
|
||||
/** OTel severityNumber. See spec: 1-4 trace, 5-8 debug, 9-12 info, 13-16 warn, 17-20 error, 21-24 fatal. */
|
||||
severityNumber?: number;
|
||||
/** Human-readable severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL). */
|
||||
severityText?: string;
|
||||
body: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
/** Unix time in nanoseconds (or Date). */
|
||||
timestamp?: number | Date;
|
||||
}
|
||||
|
||||
export interface OtelTransportOptions {
|
||||
/** @default LogLevel.INFO */
|
||||
minLevel?: LogLevel;
|
||||
/** @default 'otel' */
|
||||
name?: string;
|
||||
filter?: (entry: LogEntry) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that forwards entries to an OpenTelemetry `Logger`.
|
||||
*
|
||||
* Maps our `LogLevel` to the OTel severityNumber space defined in the spec
|
||||
* (each level has 4 numbers available; we use the base value of each band):
|
||||
* - TRACE → 1
|
||||
* - DEBUG → 5
|
||||
* - INFO → 9
|
||||
* - WARN → 13
|
||||
* - ERROR → 17
|
||||
* - FATAL → 21
|
||||
*
|
||||
* Structured context, tags, traceId and error are flattened into OTel
|
||||
* attributes using dotted keys (`log.context.userId`, `log.tags`, ...).
|
||||
*/
|
||||
export function otelTransport(
|
||||
otelLogger: OtelLoggerLike,
|
||||
options: OtelTransportOptions = {}
|
||||
): Transport {
|
||||
return {
|
||||
name: options.name ?? 'otel',
|
||||
minLevel: options.minLevel ?? LogLevel.INFO,
|
||||
filter: options.filter,
|
||||
write(entry: LogEntry): void {
|
||||
const attributes: Record<string, unknown> = {
|
||||
'log.category': entry.category
|
||||
};
|
||||
|
||||
if (entry.context) {
|
||||
for (const [key, value] of Object.entries(entry.context)) {
|
||||
attributes[`log.context.${key}`] = value;
|
||||
}
|
||||
}
|
||||
if (entry.tags?.length) attributes['log.tags'] = entry.tags;
|
||||
if (entry.traceId) attributes['log.traceId'] = entry.traceId;
|
||||
if (entry.durationMs !== undefined) attributes['log.durationMs'] = entry.durationMs;
|
||||
if (entry.source) {
|
||||
if (entry.source.file) attributes['code.filepath'] = entry.source.file;
|
||||
if (entry.source.line !== undefined) attributes['code.lineno'] = entry.source.line;
|
||||
if (entry.source.function) attributes['code.function'] = entry.source.function;
|
||||
}
|
||||
if (entry.error) {
|
||||
attributes['exception.type'] = entry.error.name;
|
||||
attributes['exception.message'] = entry.error.message;
|
||||
if (entry.error.stack) attributes['exception.stacktrace'] = entry.error.stack;
|
||||
}
|
||||
|
||||
otelLogger.emit({
|
||||
severityNumber: toOtelSeverityNumber(entry.level),
|
||||
severityText: LogLevel[entry.level],
|
||||
body: entry.message,
|
||||
attributes,
|
||||
timestamp: entry.timestamp
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toOtelSeverityNumber(level: LogLevel): number {
|
||||
switch (level) {
|
||||
case LogLevel.TRACE:
|
||||
return 1;
|
||||
case LogLevel.DEBUG:
|
||||
return 5;
|
||||
case LogLevel.INFO:
|
||||
return 9;
|
||||
case LogLevel.WARN:
|
||||
return 13;
|
||||
case LogLevel.ERROR:
|
||||
return 17;
|
||||
case LogLevel.FATAL:
|
||||
return 21;
|
||||
default:
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
import type { LogEntry, Transport } from '../types.ts';
|
||||
import { LogLevel } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Duck-typed subset of the Sentry SDK used by the transport. The `engine_logger`
|
||||
* library does not import `@sentry/browser` directly — the caller injects an
|
||||
* already-initialized SDK. This keeps the logger zero-deps and makes testing
|
||||
* with trivial mocks easy.
|
||||
*/
|
||||
export interface SentryLike {
|
||||
captureException(err: unknown): void;
|
||||
captureMessage(msg: string, level?: SentrySeverity): void;
|
||||
addBreadcrumb(breadcrumb: {
|
||||
message?: string;
|
||||
level?: SentrySeverity;
|
||||
category?: string;
|
||||
data?: Record<string, unknown>;
|
||||
timestamp?: number;
|
||||
}): void;
|
||||
withScope(cb: (scope: SentryScopeLike) => void): void;
|
||||
}
|
||||
|
||||
export interface SentryScopeLike {
|
||||
setTag(key: string, value: string): void;
|
||||
setContext(name: string, context: Record<string, unknown> | null): void;
|
||||
setFingerprint?(fingerprint: string[]): void;
|
||||
}
|
||||
|
||||
export type SentrySeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal';
|
||||
|
||||
export interface SentryTransportOptions {
|
||||
/**
|
||||
* Entries at or above `minLevel` become Sentry events (captureException/
|
||||
* captureMessage).
|
||||
* @default LogLevel.ERROR
|
||||
*/
|
||||
minLevel?: LogLevel;
|
||||
/**
|
||||
* Entries in `[breadcrumbLevel, minLevel)` become breadcrumbs. Breadcrumbs
|
||||
* are context attached to the next captured event. Pass `LogLevel.NONE` to
|
||||
* disable them entirely.
|
||||
* @default LogLevel.INFO
|
||||
*/
|
||||
breadcrumbLevel?: LogLevel;
|
||||
/** Transport name shown in introspection and `deniedFor`. @default 'sentry' */
|
||||
name?: string;
|
||||
/** Extra predicate. When it returns `false`, the entry is skipped entirely. */
|
||||
filter?: (entry: LogEntry) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport that forwards entries to the Sentry SDK.
|
||||
*
|
||||
* - ERROR+ → `captureException` when `entry.error` is set, `captureMessage`
|
||||
* otherwise.
|
||||
* - INFO..WARN → `addBreadcrumb` (attached to the next captured event).
|
||||
* - DEBUG/TRACE → `addBreadcrumb` with severity 'debug' (only if
|
||||
* `breadcrumbLevel` is low enough).
|
||||
*
|
||||
* Every event passes through `withScope` so that tags, traceId and context
|
||||
* are attached without polluting Sentry's global scope.
|
||||
*
|
||||
* @example
|
||||
* import * as Sentry from '@sentry/browser';
|
||||
* Sentry.init({ dsn: '...' });
|
||||
*
|
||||
* const logger = createEngineLogger({ level: LogLevel.DEBUG });
|
||||
* logger.addTransport(sentryTransport(Sentry));
|
||||
*
|
||||
* logger.info('auth', 'Login attempt'); // breadcrumb
|
||||
* logger.error('auth', 'Login failed', { error: err }); // event + breadcrumbs
|
||||
*/
|
||||
export function sentryTransport(
|
||||
sentry: SentryLike,
|
||||
options: SentryTransportOptions = {}
|
||||
): Transport {
|
||||
const minLevel = options.minLevel ?? LogLevel.ERROR;
|
||||
const breadcrumbLevel = options.breadcrumbLevel ?? LogLevel.INFO;
|
||||
const name = options.name ?? 'sentry';
|
||||
|
||||
return {
|
||||
name,
|
||||
// Intentionally no `transport.minLevel` here — we need to receive lower
|
||||
// levels too so they can become breadcrumbs.
|
||||
filter: options.filter,
|
||||
write(entry: LogEntry): void {
|
||||
if (entry.level < breadcrumbLevel) return;
|
||||
|
||||
if (entry.level < minLevel) {
|
||||
sentry.addBreadcrumb({
|
||||
message: entry.message,
|
||||
level: toSentrySeverity(entry.level),
|
||||
category: entry.category,
|
||||
data: entry.context,
|
||||
timestamp: entry.timestamp.getTime() / 1000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sentry.withScope((scope) => {
|
||||
scope.setTag('category', entry.category);
|
||||
if (entry.traceId) scope.setTag('traceId', entry.traceId);
|
||||
if (entry.tags?.length) {
|
||||
for (const t of entry.tags) scope.setTag(`tag:${t}`, 'true');
|
||||
}
|
||||
if (entry.context) scope.setContext('log.context', entry.context);
|
||||
if (entry.source) {
|
||||
scope.setContext(
|
||||
'log.source',
|
||||
entry.source as unknown as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
if (entry.durationMs !== undefined) {
|
||||
scope.setContext('log.timing', { durationMs: entry.durationMs });
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
const reconstructed = new Error(entry.error.message);
|
||||
reconstructed.name = entry.error.name;
|
||||
if (entry.error.stack) reconstructed.stack = entry.error.stack;
|
||||
if (entry.error.cause !== undefined) {
|
||||
(reconstructed as { cause?: unknown }).cause = entry.error.cause;
|
||||
}
|
||||
sentry.captureException(reconstructed);
|
||||
} else {
|
||||
sentry.captureMessage(entry.message, toSentrySeverity(entry.level));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toSentrySeverity(level: LogLevel): SentrySeverity {
|
||||
switch (level) {
|
||||
case LogLevel.TRACE:
|
||||
case LogLevel.DEBUG:
|
||||
return 'debug';
|
||||
case LogLevel.INFO:
|
||||
return 'info';
|
||||
case LogLevel.WARN:
|
||||
return 'warning';
|
||||
case LogLevel.ERROR:
|
||||
return 'error';
|
||||
case LogLevel.FATAL:
|
||||
return 'fatal';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Runtime identifier of the logging engine. Used for:
|
||||
* - `[engine_logger]` prefix in console output
|
||||
* - Category of internal entries (transport failures, etc.)
|
||||
* - `engine_logger` tag on synthetic entries
|
||||
*
|
||||
* Independent of the folder/alias name (`logr`, `$logr`): the directory is a
|
||||
* filesystem detail; this identifier describes the engine itself.
|
||||
*/
|
||||
export const logger = 'engine_logger';
|
||||
@ -0,0 +1,466 @@
|
||||
import type {
|
||||
LoggerOptions,
|
||||
LogEntry,
|
||||
LogInput,
|
||||
LogMessage,
|
||||
MessageCategory,
|
||||
LogFilters,
|
||||
EngineLogger,
|
||||
Transport,
|
||||
SubscriberFn,
|
||||
LogSource
|
||||
} from './types.ts';
|
||||
import { LogLevel } from './types.ts';
|
||||
import { logger } from './consts.ts';
|
||||
import { consoleTransport } from './transports.ts';
|
||||
import { captureSource, extractError } from './source.ts';
|
||||
|
||||
/**
|
||||
* Unique entry id. Uses `crypto.randomUUID()` when available (modern browsers,
|
||||
* Node 16+); falls back to a compact `timestamp-random` hex in older hosts.
|
||||
*/
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
const DEV: boolean =
|
||||
typeof import.meta !== 'undefined' && import.meta.env != null
|
||||
? import.meta.env.DEV === true
|
||||
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
|
||||
|
||||
// ============================================================================
|
||||
// ENGINE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create an `EngineLogger` instance.
|
||||
*
|
||||
* **Decoupled from i18n** — messages are plain strings. If you need
|
||||
* translated log lines, resolve them at the call site:
|
||||
* `logger.info('auth', lang.t('login.ok'))`
|
||||
*
|
||||
* Features:
|
||||
* - `globalContext` merged into every entry (appVersion, sessionId, userId…).
|
||||
* - `source` auto-captured from the stack in DEV (`file:line:col`).
|
||||
* - `error` normalized to a serializable `LogError` when passed in `input.error`.
|
||||
* - `traceId` and `tags` for correlation and fine-grained filtering.
|
||||
* - `child(ctx)` — child logger with extended context, shares history/transports.
|
||||
* - `time(label)` / `timeEnd(label)` — duration measurement.
|
||||
* - `addTransport(t)` / `subscribe(fn)` — dynamic bus (Sentry, metrics, etc.).
|
||||
* - Per-transport `filter` and `minLevel` — e.g. Sentry only ERROR+.
|
||||
* - Lazy messages via thunks — not evaluated if filtered by global level.
|
||||
*
|
||||
* @example
|
||||
* const log = createEngineLogger({
|
||||
* level: LogLevel.DEBUG,
|
||||
* globalContext: { appVersion: '1.2.3', env: 'prod' },
|
||||
* transports: [consoleTransport()]
|
||||
* });
|
||||
*
|
||||
* // Sentry as a dynamic transport
|
||||
* const detachSentry = log.addTransport({
|
||||
* name: 'sentry',
|
||||
* minLevel: LogLevel.ERROR,
|
||||
* write: (entry) => {
|
||||
* if (entry.error) Sentry.captureException(entry.error);
|
||||
* else Sentry.captureMessage(entry.message);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // Pure observer via subscribe
|
||||
* log.subscribe((entry) => {
|
||||
* if (entry.level < LogLevel.ERROR) Sentry.addBreadcrumb({ message: entry.message });
|
||||
* });
|
||||
*
|
||||
* try { ... } catch (err) {
|
||||
* log.error('db', 'Query failed', { error: err, traceId: 'req-1' });
|
||||
* }
|
||||
*/
|
||||
export function createEngineLogger(options: LoggerOptions = {}): EngineLogger {
|
||||
const captureSourceEnabled: boolean = options.captureSource ?? DEV;
|
||||
const timers = new Map<string, number>();
|
||||
|
||||
// Per-transport buffers and interval timer ids — indexed by reference.
|
||||
const buffers = new WeakMap<Transport, LogEntry[]>();
|
||||
const flushTimers = new WeakMap<Transport, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Mutable state shared between root logger and its children.
|
||||
const state = {
|
||||
level: options.level ?? LogLevel.WARN,
|
||||
maxLogs: options.maxLogs ?? 1000,
|
||||
entries: [] as LogEntry[],
|
||||
transports: [...(options.transports ?? [consoleTransport()])]
|
||||
};
|
||||
|
||||
const built = buildLogger(
|
||||
captureSourceEnabled,
|
||||
timers,
|
||||
buffers,
|
||||
flushTimers,
|
||||
state,
|
||||
{ ...options.globalContext }
|
||||
);
|
||||
|
||||
// Browser: flush everything before the page unloads so buffered entries
|
||||
// get a chance to reach their destinations.
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('beforeunload', () => built.flush());
|
||||
}
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
interface LoggerState {
|
||||
level: LogLevel;
|
||||
maxLogs: number;
|
||||
entries: LogEntry[];
|
||||
transports: Transport[];
|
||||
}
|
||||
|
||||
function buildLogger(
|
||||
captureSourceEnabled: boolean,
|
||||
timers: Map<string, number>,
|
||||
buffers: WeakMap<Transport, LogEntry[]>,
|
||||
flushTimers: WeakMap<Transport, ReturnType<typeof setTimeout>>,
|
||||
state: LoggerState,
|
||||
globalContext: Record<string, unknown>
|
||||
): EngineLogger {
|
||||
function mergeContext(input?: LogInput): Record<string, unknown> | undefined {
|
||||
const inputCtx = input?.context;
|
||||
const hasGlobal = Object.keys(globalContext).length > 0;
|
||||
if (!inputCtx && !hasGlobal) return undefined;
|
||||
return { ...globalContext, ...inputCtx };
|
||||
}
|
||||
|
||||
function resolveMessage(message: LogMessage): string {
|
||||
if (typeof message === 'function') {
|
||||
try {
|
||||
return message();
|
||||
} catch (err) {
|
||||
return `[${logger}] thunk threw: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function log(
|
||||
lvl: LogLevel,
|
||||
category: MessageCategory,
|
||||
message: LogMessage,
|
||||
input: LogInput | undefined,
|
||||
skipFrames: number
|
||||
): void {
|
||||
// Global level check first: if dropped, skip thunk evaluation and source capture.
|
||||
if (lvl < state.level) return;
|
||||
|
||||
const resolved = resolveMessage(message);
|
||||
const source: LogSource | undefined =
|
||||
input?.source ?? (captureSourceEnabled ? captureSource(skipFrames) : undefined);
|
||||
|
||||
const entry: LogEntry = {
|
||||
id: generateId(),
|
||||
timestamp: new Date(),
|
||||
level: lvl,
|
||||
category,
|
||||
message: resolved,
|
||||
context: mergeContext(input),
|
||||
error: input?.error !== undefined ? extractError(input.error) : undefined,
|
||||
tags: input?.tags,
|
||||
traceId: input?.traceId,
|
||||
durationMs: input?.durationMs,
|
||||
source
|
||||
};
|
||||
|
||||
pushEntry(entry);
|
||||
dispatch(entry, undefined);
|
||||
}
|
||||
|
||||
function pushEntry(entry: LogEntry): void {
|
||||
state.entries.push(entry);
|
||||
if (state.entries.length > state.maxLogs) {
|
||||
state.entries = state.entries.slice(-state.maxLogs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an entry to the active transports.
|
||||
*
|
||||
* - `skipTransport` excludes a specific transport by reference (used when
|
||||
* re-injecting a failure entry so the transport that failed does not
|
||||
* receive the report of its own error — prevents cascades).
|
||||
* - Sync throws and promise rejections are routed to the same failure handler,
|
||||
* except when the entry is already a failure report (`deniedFor` set):
|
||||
* a second-level failure is swallowed to guarantee no cascade loop.
|
||||
* - If `transport.buffer > 0`, entries are queued and flushed when the
|
||||
* buffer is full or the interval timer fires.
|
||||
*/
|
||||
function dispatch(entry: LogEntry, skipTransport: Transport | undefined): void {
|
||||
const isFailureReport = entry.deniedFor !== undefined;
|
||||
|
||||
for (const transport of state.transports) {
|
||||
if (transport === skipTransport) continue;
|
||||
if (transport.minLevel !== undefined && entry.level < transport.minLevel) continue;
|
||||
if (transport.filter && !transport.filter(entry)) continue;
|
||||
|
||||
const bufferSize = transport.buffer ?? 0;
|
||||
if (bufferSize <= 0) {
|
||||
writeOne(transport, entry, isFailureReport);
|
||||
} else {
|
||||
enqueue(transport, entry, bufferSize, isFailureReport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeOne(transport: Transport, entry: LogEntry, isFailureReport: boolean): void {
|
||||
try {
|
||||
const ret = transport.write(entry);
|
||||
if (ret && typeof ret.then === 'function') {
|
||||
ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport));
|
||||
}
|
||||
} catch (err) {
|
||||
handleFailure(transport, err, isFailureReport);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(
|
||||
transport: Transport,
|
||||
entry: LogEntry,
|
||||
maxSize: number,
|
||||
isFailureReport: boolean
|
||||
): void {
|
||||
let buf = buffers.get(transport);
|
||||
if (!buf) {
|
||||
buf = [];
|
||||
buffers.set(transport, buf);
|
||||
}
|
||||
buf.push(entry);
|
||||
|
||||
// Arm the time-based flush timer on the first enqueue of the cycle.
|
||||
if (
|
||||
transport.flushIntervalMs !== undefined &&
|
||||
transport.flushIntervalMs > 0 &&
|
||||
!flushTimers.has(transport)
|
||||
) {
|
||||
const id = setTimeout(() => flushTransport(transport, isFailureReport), transport.flushIntervalMs);
|
||||
flushTimers.set(transport, id);
|
||||
}
|
||||
|
||||
if (buf.length >= maxSize) {
|
||||
flushTransport(transport, isFailureReport);
|
||||
}
|
||||
}
|
||||
|
||||
function flushTransport(transport: Transport, isFailureReport: boolean): void {
|
||||
const buf = buffers.get(transport);
|
||||
if (!buf || buf.length === 0) {
|
||||
clearFlushTimer(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
// Take ownership of the buffer and clear the pending timer.
|
||||
buffers.delete(transport);
|
||||
clearFlushTimer(transport);
|
||||
|
||||
if (transport.writeBatch) {
|
||||
try {
|
||||
const ret = transport.writeBatch(buf);
|
||||
if (ret && typeof ret.then === 'function') {
|
||||
ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport));
|
||||
}
|
||||
} catch (err) {
|
||||
handleFailure(transport, err, isFailureReport);
|
||||
}
|
||||
} else {
|
||||
for (const entry of buf) writeOne(transport, entry, isFailureReport);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFlushTimer(transport: Transport): void {
|
||||
const id = flushTimers.get(transport);
|
||||
if (id !== undefined) {
|
||||
clearTimeout(id);
|
||||
flushTimers.delete(transport);
|
||||
}
|
||||
}
|
||||
|
||||
function flushAll(): void {
|
||||
for (const transport of state.transports) {
|
||||
flushTransport(transport, false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFailure(
|
||||
failed: Transport,
|
||||
err: unknown,
|
||||
suppressFailureReport: boolean
|
||||
): void {
|
||||
const failedName = failed.name ?? 'anonymous';
|
||||
console.error(`[${logger}] Transport "${failedName}" error:`, err);
|
||||
|
||||
// Cascade guard: if the failure happens while re-dispatching a failure
|
||||
// entry, do not synthesize a second report. Console-only.
|
||||
if (suppressFailureReport) return;
|
||||
|
||||
const errorObj = extractError(err);
|
||||
const failureEntry: LogEntry = {
|
||||
id: generateId(),
|
||||
timestamp: new Date(),
|
||||
level: LogLevel.ERROR,
|
||||
category: logger,
|
||||
message: `Transport "${failedName}" failed: ${errorObj.message}`,
|
||||
context: mergeContext(undefined),
|
||||
error: errorObj,
|
||||
tags: [logger, 'transport-failure'],
|
||||
deniedFor: [failedName]
|
||||
};
|
||||
|
||||
pushEntry(failureEntry);
|
||||
dispatch(failureEntry, failed);
|
||||
}
|
||||
|
||||
return {
|
||||
trace(category, message, input) {
|
||||
log(LogLevel.TRACE, category, message, input, 3);
|
||||
},
|
||||
debug(category, message, input) {
|
||||
log(LogLevel.DEBUG, category, message, input, 3);
|
||||
},
|
||||
info(category, message, input) {
|
||||
log(LogLevel.INFO, category, message, input, 3);
|
||||
},
|
||||
warn(category, message, input) {
|
||||
log(LogLevel.WARN, category, message, input, 3);
|
||||
},
|
||||
error(category, message, input) {
|
||||
log(LogLevel.ERROR, category, message, input, 3);
|
||||
},
|
||||
fatal(category, message, input) {
|
||||
log(LogLevel.FATAL, category, message, input, 3);
|
||||
},
|
||||
|
||||
getLogs(filters?: LogFilters): LogEntry[] {
|
||||
let result = state.entries.map((e) => ({ ...e }));
|
||||
|
||||
if (filters?.level !== undefined) result = result.filter((e) => e.level === filters.level);
|
||||
if (filters?.minLevel !== undefined)
|
||||
result = result.filter((e) => e.level >= filters.minLevel!);
|
||||
if (filters?.category !== undefined)
|
||||
result = result.filter((e) => e.category === filters.category);
|
||||
if (filters?.since !== undefined)
|
||||
result = result.filter((e) => e.timestamp >= filters.since!);
|
||||
if (filters?.tag !== undefined) {
|
||||
const tag = filters.tag;
|
||||
result = result.filter((e) => e.tags?.includes(tag) ?? false);
|
||||
}
|
||||
if (filters?.traceId !== undefined)
|
||||
result = result.filter((e) => e.traceId === filters.traceId);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
state.entries = [];
|
||||
},
|
||||
|
||||
serialize(): string {
|
||||
const seen = new WeakSet<object>();
|
||||
const safeReplacer = (_key: string, value: unknown) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) return '[Circular]';
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return JSON.stringify(
|
||||
state.entries.map((e) => ({
|
||||
...e,
|
||||
timestamp: e.timestamp.toISOString()
|
||||
})),
|
||||
safeReplacer,
|
||||
2
|
||||
);
|
||||
},
|
||||
|
||||
setLevel(l: LogLevel): void {
|
||||
state.level = l;
|
||||
},
|
||||
|
||||
setMaxLogs(max: number): void {
|
||||
const normalized = !Number.isFinite(max) || max < 1 ? 1 : Math.floor(max);
|
||||
state.maxLogs = normalized;
|
||||
if (state.entries.length > normalized) {
|
||||
state.entries = state.entries.slice(-normalized);
|
||||
}
|
||||
},
|
||||
|
||||
setGlobalContext(ctx: Record<string, unknown>): void {
|
||||
globalContext = { ...ctx };
|
||||
},
|
||||
|
||||
addTransport(transport: Transport): () => void {
|
||||
state.transports.push(transport);
|
||||
return () => {
|
||||
// Flush anything buffered so data isn't lost on detach.
|
||||
flushTransport(transport, false);
|
||||
const idx = state.transports.indexOf(transport);
|
||||
if (idx !== -1) state.transports.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
|
||||
subscribe(fn: SubscriberFn): () => void {
|
||||
const transport: Transport = { write: fn, name: 'subscriber' };
|
||||
state.transports.push(transport);
|
||||
return () => {
|
||||
flushTransport(transport, false);
|
||||
const idx = state.transports.indexOf(transport);
|
||||
if (idx !== -1) state.transports.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
|
||||
removeAllTransports(): void {
|
||||
flushAll();
|
||||
state.transports = [];
|
||||
},
|
||||
|
||||
transports(): readonly Transport[] {
|
||||
return state.transports;
|
||||
},
|
||||
|
||||
child(ctx: Record<string, unknown>): EngineLogger {
|
||||
return buildLogger(captureSourceEnabled, timers, buffers, flushTimers, state, {
|
||||
...globalContext,
|
||||
...ctx
|
||||
});
|
||||
},
|
||||
|
||||
time(label: string): void {
|
||||
timers.set(label, performance.now());
|
||||
},
|
||||
|
||||
timeEnd(label: string, category: MessageCategory = 'timer', message?: LogMessage): void {
|
||||
const start = timers.get(label);
|
||||
if (start === undefined) {
|
||||
log(
|
||||
LogLevel.WARN,
|
||||
'timer',
|
||||
`[${logger}] timeEnd("${label}") called without matching time()`,
|
||||
undefined,
|
||||
3
|
||||
);
|
||||
return;
|
||||
}
|
||||
timers.delete(label);
|
||||
const durationMs = performance.now() - start;
|
||||
const msg = message ?? label;
|
||||
log(LogLevel.INFO, category, msg, { durationMs, tags: ['timer'] }, 3);
|
||||
},
|
||||
|
||||
flush(): void {
|
||||
flushAll();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from './engine-logger.ts';
|
||||
export * from './transports.ts';
|
||||
export * from './types.ts';
|
||||
@ -0,0 +1,99 @@
|
||||
import type { LogSource } from './types.ts';
|
||||
|
||||
/**
|
||||
* Capture the caller location (file:line:column) by parsing `new Error().stack`.
|
||||
*
|
||||
* @param skip Number of internal frames to skip before the actual caller.
|
||||
* Depends on the call depth inside the logger itself.
|
||||
*
|
||||
* V8 format (Chrome/Node/Edge):
|
||||
* " at fnName (file:///path:10:15)"
|
||||
* " at file:///path:10:15"
|
||||
*
|
||||
* SpiderMonkey/JSC format (Firefox/Safari):
|
||||
* "fnName@file:///path:10:15"
|
||||
*
|
||||
* In minified production builds the function names are mangled — the caller
|
||||
* can opt out via `captureSource: false` in LoggerOptions.
|
||||
*/
|
||||
export function captureSource(skip: number): LogSource | undefined {
|
||||
const err = new Error();
|
||||
const stack = err.stack;
|
||||
if (!stack) return undefined;
|
||||
|
||||
const lines = stack.split('\n');
|
||||
// First line is either "Error" (V8) or the first frame (SpiderMonkey).
|
||||
const startIdx = lines[0]?.trim().startsWith('at ') || lines[0]?.includes('@') ? 0 : 1;
|
||||
const frameLine = lines[startIdx + skip];
|
||||
if (!frameLine) return undefined;
|
||||
|
||||
return parseFrame(frameLine);
|
||||
}
|
||||
|
||||
function parseFrame(frame: string): LogSource | undefined {
|
||||
const trimmed = frame.trim();
|
||||
|
||||
// V8: "at fnName (file:line:col)" or "at file:line:col"
|
||||
const v8Match =
|
||||
trimmed.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/) ??
|
||||
trimmed.match(/^at\s+(.+):(\d+):(\d+)$/);
|
||||
|
||||
if (v8Match) {
|
||||
if (v8Match.length === 5) {
|
||||
return {
|
||||
function: v8Match[1],
|
||||
file: v8Match[2],
|
||||
line: Number(v8Match[3]),
|
||||
column: Number(v8Match[4])
|
||||
};
|
||||
}
|
||||
return {
|
||||
file: v8Match[1],
|
||||
line: Number(v8Match[2]),
|
||||
column: Number(v8Match[3])
|
||||
};
|
||||
}
|
||||
|
||||
// SpiderMonkey/JSC: "fnName@file:line:col" or "@file:line:col"
|
||||
const mozMatch = trimmed.match(/^(.*?)@(.+):(\d+):(\d+)$/);
|
||||
if (mozMatch) {
|
||||
const fn = mozMatch[1];
|
||||
return {
|
||||
function: fn || undefined,
|
||||
file: mozMatch[2],
|
||||
line: Number(mozMatch[3]),
|
||||
column: Number(mozMatch[4])
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an `Error` (or any value caught in a try/catch) into the
|
||||
* serializable `LogError` shape. Accepts `unknown` because thrown values are
|
||||
* not always `Error` instances — strings, objects, etc.
|
||||
*/
|
||||
export function extractError(value: unknown): import('./types.ts').LogError {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
cause: value.cause
|
||||
};
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const obj = value as { name?: unknown; message?: unknown; stack?: unknown; cause?: unknown };
|
||||
return {
|
||||
name: typeof obj.name === 'string' ? obj.name : 'Unknown',
|
||||
message: typeof obj.message === 'string' ? obj.message : String(value),
|
||||
stack: typeof obj.stack === 'string' ? obj.stack : undefined,
|
||||
cause: obj.cause
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: 'Unknown',
|
||||
message: String(value)
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,733 @@
|
||||
/**
|
||||
* logr — test suite
|
||||
*
|
||||
* Covers:
|
||||
* - Level filtering (global)
|
||||
* - All six level methods (trace..fatal)
|
||||
* - globalContext merge
|
||||
* - Lazy messages (thunks)
|
||||
* - source capture
|
||||
* - error extraction
|
||||
* - tags, traceId
|
||||
* - time / timeEnd
|
||||
* - child()
|
||||
* - getLogs filters
|
||||
* - setLevel, setMaxLogs, setGlobalContext
|
||||
* - addTransport, subscribe, removeAllTransports, transports()
|
||||
* - Per-transport minLevel + filter
|
||||
* - Transport failure → deniedFor (sync + async)
|
||||
* - Cascade guard
|
||||
* - serialize()
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
LogLevel,
|
||||
createEngineLogger,
|
||||
callbackTransport,
|
||||
type LogEntry,
|
||||
type Transport
|
||||
} from '../index.ts';
|
||||
import { extractError } from '../source.ts';
|
||||
import { logger as LOGGER_NAME } from '../consts.ts';
|
||||
|
||||
function capture(): { transport: Transport; entries: LogEntry[] } {
|
||||
const entries: LogEntry[] = [];
|
||||
const transport: Transport = {
|
||||
name: 'capture',
|
||||
write(entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
};
|
||||
return { transport, entries };
|
||||
}
|
||||
|
||||
describe('createEngineLogger — level methods', () => {
|
||||
it('emits each of the 6 levels', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
|
||||
log.trace('t', 'trace');
|
||||
log.debug('t', 'debug');
|
||||
log.info('t', 'info');
|
||||
log.warn('t', 'warn');
|
||||
log.error('t', 'error');
|
||||
log.fatal('t', 'fatal');
|
||||
|
||||
expect(entries.map((e) => e.level)).toEqual([
|
||||
LogLevel.TRACE,
|
||||
LogLevel.DEBUG,
|
||||
LogLevel.INFO,
|
||||
LogLevel.WARN,
|
||||
LogLevel.ERROR,
|
||||
LogLevel.FATAL
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops entries below the global level', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.WARN, transports: [transport] });
|
||||
|
||||
log.trace('t', 'dropped');
|
||||
log.debug('t', 'dropped');
|
||||
log.info('t', 'dropped');
|
||||
log.warn('t', 'kept');
|
||||
log.error('t', 'kept');
|
||||
log.fatal('t', 'kept');
|
||||
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries.map((e) => e.level)).toEqual([LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]);
|
||||
});
|
||||
|
||||
it('setLevel raises/lowers the filter at runtime', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
|
||||
log.info('t', 'one');
|
||||
log.setLevel(LogLevel.ERROR);
|
||||
log.info('t', 'dropped');
|
||||
log.error('t', 'two');
|
||||
|
||||
expect(entries.map((e) => e.message)).toEqual(['one', 'two']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — lazy messages', () => {
|
||||
it('does not evaluate the thunk when filtered by level', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.ERROR, transports: [transport] });
|
||||
const thunk = vi.fn(() => 'expensive');
|
||||
log.debug('t', thunk);
|
||||
expect(thunk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evaluates the thunk when the entry passes', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', () => 'expensive');
|
||||
expect(entries[0].message).toBe('expensive');
|
||||
});
|
||||
|
||||
it('captures thunk errors as the resolved message', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(entries[0].message).toContain('thunk threw');
|
||||
expect(entries[0].message).toContain('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — globalContext', () => {
|
||||
it('merges globalContext into every entry context', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
globalContext: { appVersion: '1.0.0' },
|
||||
transports: [transport]
|
||||
});
|
||||
log.info('t', 'hello', { context: { userId: 42 } });
|
||||
expect(entries[0].context).toEqual({ appVersion: '1.0.0', userId: 42 });
|
||||
});
|
||||
|
||||
it('call-site context wins on key collision', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
globalContext: { env: 'dev' },
|
||||
transports: [transport]
|
||||
});
|
||||
log.info('t', 'hello', { context: { env: 'prod' } });
|
||||
expect(entries[0].context).toEqual({ env: 'prod' });
|
||||
});
|
||||
|
||||
it('setGlobalContext replaces the existing context', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
globalContext: { a: 1 },
|
||||
transports: [transport]
|
||||
});
|
||||
log.setGlobalContext({ b: 2 });
|
||||
log.info('t', 'hello');
|
||||
expect(entries[0].context).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it('entry has undefined context when no global and no local context exist', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'hello');
|
||||
expect(entries[0].context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — error / traceId / tags / durationMs', () => {
|
||||
it('extracts an Error to LogError shape', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
const err = new Error('boom');
|
||||
log.error('t', 'failed', { error: err });
|
||||
expect(entries[0].error).toMatchObject({ name: 'Error', message: 'boom' });
|
||||
expect(entries[0].error?.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts non-Error values as error', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.error('t', 'failed', { error: 'string-err' });
|
||||
expect(entries[0].error?.message).toBe('string-err');
|
||||
});
|
||||
|
||||
it('preserves traceId, tags and durationMs on the entry', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'hello', {
|
||||
traceId: 'req-1',
|
||||
tags: ['auth', 'critical'],
|
||||
durationMs: 123
|
||||
});
|
||||
expect(entries[0].traceId).toBe('req-1');
|
||||
expect(entries[0].tags).toEqual(['auth', 'critical']);
|
||||
expect(entries[0].durationMs).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractError', () => {
|
||||
it('handles Error instances', () => {
|
||||
const err = new Error('boom');
|
||||
const result = extractError(err);
|
||||
expect(result.name).toBe('Error');
|
||||
expect(result.message).toBe('boom');
|
||||
expect(result.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles strings', () => {
|
||||
expect(extractError('oops')).toMatchObject({ name: 'Unknown', message: 'oops' });
|
||||
});
|
||||
|
||||
it('handles plain objects with message', () => {
|
||||
expect(extractError({ message: 'obj-fail' })).toMatchObject({ message: 'obj-fail' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — time / timeEnd', () => {
|
||||
it('emits an INFO entry with durationMs and timer tag', async () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
|
||||
log.time('op');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
log.timeEnd('op', 'perf', 'operation done');
|
||||
|
||||
const last = entries[entries.length - 1];
|
||||
expect(last.level).toBe(LogLevel.INFO);
|
||||
expect(last.category).toBe('perf');
|
||||
expect(last.message).toBe('operation done');
|
||||
expect(last.durationMs).toBeGreaterThan(0);
|
||||
expect(last.tags).toEqual(['timer']);
|
||||
});
|
||||
|
||||
it('warns when timeEnd has no matching time', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.timeEnd('missing');
|
||||
expect(entries[0].level).toBe(LogLevel.WARN);
|
||||
expect(entries[0].message).toContain('timeEnd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — child()', () => {
|
||||
it('extends globalContext and shares history', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
globalContext: { app: 'X' },
|
||||
transports: [transport]
|
||||
});
|
||||
const child = log.child({ requestId: 'r1' });
|
||||
child.info('t', 'via child');
|
||||
log.info('t', 'via parent');
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].context).toEqual({ app: 'X', requestId: 'r1' });
|
||||
expect(entries[1].context).toEqual({ app: 'X' });
|
||||
});
|
||||
|
||||
it('children share the transports list with the parent', () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE });
|
||||
const child = log.child({});
|
||||
const parentCount = log.transports().length;
|
||||
child.addTransport({ name: 'extra', write: () => {} });
|
||||
expect(log.transports().length).toBe(parentCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — getLogs filters', () => {
|
||||
it('filters by exact level', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.debug('t', 'a');
|
||||
log.info('t', 'b');
|
||||
log.debug('t', 'c');
|
||||
expect(log.getLogs({ level: LogLevel.DEBUG })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters by minLevel', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a');
|
||||
log.warn('t', 'b');
|
||||
log.error('t', 'c');
|
||||
expect(log.getLogs({ minLevel: LogLevel.WARN })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters by category', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('auth', 'a');
|
||||
log.info('db', 'b');
|
||||
expect(log.getLogs({ category: 'auth' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by tag', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a', { tags: ['x', 'y'] });
|
||||
log.info('t', 'b', { tags: ['y', 'z'] });
|
||||
expect(log.getLogs({ tag: 'x' })).toHaveLength(1);
|
||||
expect(log.getLogs({ tag: 'y' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters by traceId', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a', { traceId: '1' });
|
||||
log.info('t', 'b', { traceId: '2' });
|
||||
expect(log.getLogs({ traceId: '1' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters by since', async () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a');
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const now = new Date();
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
log.info('t', 'b');
|
||||
expect(log.getLogs({ since: now })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('combines filters with AND', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('auth', 'a');
|
||||
log.warn('auth', 'b');
|
||||
log.error('db', 'c');
|
||||
expect(log.getLogs({ category: 'auth', minLevel: LogLevel.WARN })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a defensive copy', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'hi');
|
||||
const logs = log.getLogs();
|
||||
logs[0].message = 'modified';
|
||||
expect(log.getLogs()[0].message).toBe('hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — maxLogs', () => {
|
||||
it('trims history to maxLogs', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
maxLogs: 3,
|
||||
transports: [transport]
|
||||
});
|
||||
for (let i = 0; i < 5; i++) log.info('t', String(i));
|
||||
expect(log.getLogs()).toHaveLength(3);
|
||||
expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3', '4']);
|
||||
});
|
||||
|
||||
it('setMaxLogs trims immediately when shrinking', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
for (let i = 0; i < 4; i++) log.info('t', String(i));
|
||||
log.setMaxLogs(2);
|
||||
expect(log.getLogs()).toHaveLength(2);
|
||||
expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — dynamic transports', () => {
|
||||
it('addTransport receives entries emitted afterwards', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.info('t', 'before');
|
||||
log.addTransport(transport);
|
||||
log.info('t', 'after');
|
||||
expect(entries.map((e) => e.message)).toEqual(['after']);
|
||||
});
|
||||
|
||||
it('addTransport remove function detaches it', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
const remove = log.addTransport(transport);
|
||||
log.info('t', 'one');
|
||||
remove();
|
||||
log.info('t', 'two');
|
||||
expect(entries.map((e) => e.message)).toEqual(['one']);
|
||||
});
|
||||
|
||||
it('subscribe is sugar over addTransport', () => {
|
||||
const received: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
const unsub = log.subscribe((e) => received.push(e));
|
||||
log.info('t', 'x');
|
||||
unsub();
|
||||
log.info('t', 'y');
|
||||
expect(received.map((e) => e.message)).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('removeAllTransports detaches everything', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a');
|
||||
log.removeAllTransports();
|
||||
log.info('t', 'b');
|
||||
expect(entries.map((e) => e.message)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('transports() returns a read-only view', () => {
|
||||
const { transport } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
expect(log.transports().length).toBe(1);
|
||||
expect(log.transports()[0].name).toBe('capture');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — per-transport minLevel and filter', () => {
|
||||
it('drops entries below transport.minLevel', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE });
|
||||
log.removeAllTransports();
|
||||
log.addTransport({ name: 't', minLevel: LogLevel.WARN, write: (e) => entries.push(e) });
|
||||
log.info('t', 'dropped');
|
||||
log.warn('t', 'kept');
|
||||
expect(entries.map((e) => e.message)).toEqual(['kept']);
|
||||
});
|
||||
|
||||
it('drops entries when predicate returns false', () => {
|
||||
const entries: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE });
|
||||
log.removeAllTransports();
|
||||
log.addTransport({
|
||||
name: 't',
|
||||
filter: (e) => e.category === 'kept',
|
||||
write: (e) => entries.push(e)
|
||||
});
|
||||
log.info('dropped', 'a');
|
||||
log.info('kept', 'b');
|
||||
expect(entries.map((e) => e.category)).toEqual(['kept']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — transport failure and deniedFor', () => {
|
||||
it('sync throw routes a failure entry to the rest', () => {
|
||||
const seenByOther: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
|
||||
const failing: Transport = {
|
||||
name: 'bad',
|
||||
write() {
|
||||
throw new Error('sync boom');
|
||||
}
|
||||
};
|
||||
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
|
||||
|
||||
log.addTransport(failing);
|
||||
log.addTransport(other);
|
||||
|
||||
log.error('t', 'original');
|
||||
|
||||
// `other` saw both: the failure report (dispatched inside handleFailure,
|
||||
// which runs before the outer dispatch loop continues) and then the
|
||||
// original entry.
|
||||
expect(seenByOther).toHaveLength(2);
|
||||
|
||||
const failureEntry = seenByOther[0];
|
||||
const originalEntry = seenByOther[1];
|
||||
expect(originalEntry.message).toBe('original');
|
||||
expect(failureEntry.category).toBe(LOGGER_NAME);
|
||||
expect(failureEntry.deniedFor).toEqual(['bad']);
|
||||
expect(failureEntry.tags).toContain('transport-failure');
|
||||
expect(failureEntry.error?.message).toContain('sync boom');
|
||||
});
|
||||
|
||||
it('async rejection triggers the same failure flow', async () => {
|
||||
const seenByOther: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
|
||||
const failing: Transport = {
|
||||
name: 'bad-async',
|
||||
async write() {
|
||||
throw new Error('async boom');
|
||||
}
|
||||
};
|
||||
const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) };
|
||||
|
||||
log.addTransport(failing);
|
||||
log.addTransport(other);
|
||||
|
||||
log.error('t', 'original');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Async case: original is dispatched first (sync loop), then the
|
||||
// promise rejects and the failure entry is dispatched to `other`.
|
||||
expect(seenByOther).toHaveLength(2);
|
||||
expect(seenByOther[0].message).toBe('original');
|
||||
expect(seenByOther[1].deniedFor).toEqual(['bad-async']);
|
||||
expect(seenByOther[1].error?.message).toContain('async boom');
|
||||
});
|
||||
|
||||
it('failed transport does NOT receive the failure report (no loop)', () => {
|
||||
let throwCount = 0;
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
|
||||
const failing: Transport = {
|
||||
name: 'bad',
|
||||
write() {
|
||||
throwCount++;
|
||||
throw new Error('boom');
|
||||
}
|
||||
};
|
||||
const other: Transport = { name: 'good', write: () => {} };
|
||||
|
||||
log.addTransport(failing);
|
||||
log.addTransport(other);
|
||||
|
||||
log.error('t', 'original');
|
||||
|
||||
// One throw only — the failed transport did not receive the failure report.
|
||||
expect(throwCount).toBe(1);
|
||||
});
|
||||
|
||||
it('cascade guard: a second-level failure is swallowed', () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'a',
|
||||
write() {
|
||||
throw new Error('a failed');
|
||||
}
|
||||
});
|
||||
log.addTransport({
|
||||
name: 'b',
|
||||
write() {
|
||||
throw new Error('b failed');
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => log.error('t', 'original')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbackTransport', () => {
|
||||
it('invokes the callback on matching entries', () => {
|
||||
const hits: LogEntry[] = [];
|
||||
const cb = callbackTransport((e) => hits.push(e));
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
transports: [cb]
|
||||
});
|
||||
log.info('t', 'a');
|
||||
expect(hits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('respects minLevel option', () => {
|
||||
const hits: LogEntry[] = [];
|
||||
const cb = callbackTransport((e) => hits.push(e), { minLevel: LogLevel.ERROR });
|
||||
const log = createEngineLogger({
|
||||
level: LogLevel.TRACE,
|
||||
transports: [cb]
|
||||
});
|
||||
log.warn('t', 'dropped');
|
||||
log.error('t', 'kept');
|
||||
expect(hits.map((e) => e.message)).toEqual(['kept']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — entry id', () => {
|
||||
it('auto-generates a unique id per entry', () => {
|
||||
const { transport, entries } = capture();
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] });
|
||||
log.info('t', 'a');
|
||||
log.info('t', 'b');
|
||||
expect(typeof entries[0].id).toBe('string');
|
||||
expect(entries[0].id.length).toBeGreaterThan(0);
|
||||
expect(entries[0].id).not.toBe(entries[1].id);
|
||||
});
|
||||
|
||||
it('failure entries also carry an id', () => {
|
||||
const seen: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({ name: 'bad', write: () => { throw new Error('x'); } });
|
||||
log.addTransport({ name: 'good', write: (e) => seen.push(e) });
|
||||
log.error('t', 'msg');
|
||||
const failureEntry = seen.find((e) => e.deniedFor !== undefined);
|
||||
expect(failureEntry?.id).toBeDefined();
|
||||
expect(typeof failureEntry?.id).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — buffering', () => {
|
||||
it('default buffer=0 delivers immediately', () => {
|
||||
const calls: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({ name: 'nobuf', write: (e) => calls.push(e) });
|
||||
log.info('t', 'a');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('holds entries until the buffer fills, then flushes', () => {
|
||||
const writeCalls: LogEntry[] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'buf',
|
||||
buffer: 3,
|
||||
write: (e) => writeCalls.push(e)
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
log.info('t', 'b');
|
||||
expect(writeCalls).toHaveLength(0);
|
||||
|
||||
log.info('t', 'c'); // reaches buffer size → flush
|
||||
expect(writeCalls).toHaveLength(3);
|
||||
expect(writeCalls.map((e) => e.message)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('uses writeBatch when available (single call with all entries)', () => {
|
||||
const batchCalls: LogEntry[][] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'batch',
|
||||
buffer: 3,
|
||||
write: () => { throw new Error('write should not be called when writeBatch exists'); },
|
||||
writeBatch: (entries) => { batchCalls.push(entries); }
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
log.info('t', 'b');
|
||||
log.info('t', 'c');
|
||||
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
expect(batchCalls[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('logger.flush() drains everything immediately', () => {
|
||||
const batchCalls: LogEntry[][] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'batch',
|
||||
buffer: 100,
|
||||
write: () => {},
|
||||
writeBatch: (entries) => { batchCalls.push(entries); }
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
log.info('t', 'b');
|
||||
expect(batchCalls).toHaveLength(0);
|
||||
|
||||
log.flush();
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
expect(batchCalls[0].map((e) => e.message)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('flushes on transport detach', () => {
|
||||
const batchCalls: LogEntry[][] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
const remove = log.addTransport({
|
||||
name: 'batch',
|
||||
buffer: 10,
|
||||
write: () => {},
|
||||
writeBatch: (entries) => { batchCalls.push(entries); }
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
log.info('t', 'b');
|
||||
remove();
|
||||
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
expect(batchCalls[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('removeAllTransports flushes everything before detach', () => {
|
||||
const batchCalls: LogEntry[][] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'batch',
|
||||
buffer: 10,
|
||||
write: () => {},
|
||||
writeBatch: (entries) => { batchCalls.push(entries); }
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
log.removeAllTransports();
|
||||
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('time-based flush via flushIntervalMs', async () => {
|
||||
const batchCalls: LogEntry[][] = [];
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.addTransport({
|
||||
name: 'batch',
|
||||
buffer: 100,
|
||||
flushIntervalMs: 20,
|
||||
write: () => {},
|
||||
writeBatch: (entries) => { batchCalls.push(entries); }
|
||||
});
|
||||
|
||||
log.info('t', 'a');
|
||||
expect(batchCalls).toHaveLength(0);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(batchCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEngineLogger — serialize()', () => {
|
||||
it('produces valid JSON including structured fields', () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
log.info('auth', 'login', {
|
||||
context: { userId: 1 },
|
||||
tags: ['auth'],
|
||||
traceId: 'abc'
|
||||
});
|
||||
|
||||
const json = log.serialize();
|
||||
const parsed = JSON.parse(json) as Array<{
|
||||
message: string;
|
||||
context: Record<string, unknown>;
|
||||
tags: string[];
|
||||
traceId: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].message).toBe('login');
|
||||
expect(parsed[0].context).toEqual({ userId: 1 });
|
||||
expect(parsed[0].tags).toEqual(['auth']);
|
||||
expect(parsed[0].traceId).toBe('abc');
|
||||
expect(typeof parsed[0].timestamp).toBe('string');
|
||||
});
|
||||
|
||||
it('handles circular references gracefully', () => {
|
||||
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
log.info('t', 'hi', { context: circular });
|
||||
expect(() => log.serialize()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,169 @@
|
||||
import type {
|
||||
Transport,
|
||||
ConsoleTransportOptions,
|
||||
HttpTransportOptions,
|
||||
LogEntry
|
||||
} from './types.ts';
|
||||
|
||||
import { LogLevel } from './types.ts';
|
||||
import { logger } from './consts.ts';
|
||||
|
||||
// ============================================================================
|
||||
// CONSOLE TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Emit log entries to the browser/Node console using the method that matches
|
||||
* the level: DEBUG → console.debug, INFO → console.info, WARN → console.warn,
|
||||
* ERROR → console.error. TRACE maps to `console.debug`, FATAL to `console.error`
|
||||
* since consoles don't expose those levels natively.
|
||||
*
|
||||
* @example
|
||||
* consoleTransport()
|
||||
* consoleTransport({ timestamp: false, prefix: false })
|
||||
* consoleTransport({ minLevel: LogLevel.INFO })
|
||||
*/
|
||||
export function consoleTransport(options: ConsoleTransportOptions = {}): Transport {
|
||||
const {
|
||||
timestamp: showTimestamp = true,
|
||||
prefix: showPrefix = true,
|
||||
source: showSource = true,
|
||||
minLevel
|
||||
} = options;
|
||||
|
||||
return {
|
||||
name: 'console',
|
||||
minLevel,
|
||||
write(entry: LogEntry): void {
|
||||
const args: unknown[] = [];
|
||||
|
||||
if (showTimestamp) args.push(entry.timestamp.toISOString());
|
||||
if (showPrefix) args.push(`[${entry.category}]`);
|
||||
if (showSource && entry.source?.file) {
|
||||
const file = entry.source.file;
|
||||
const afterSlash = file.slice(file.lastIndexOf('/') + 1);
|
||||
const clean = afterSlash.split('?')[0];
|
||||
args.push(`(${clean}:${entry.source.line})`);
|
||||
}
|
||||
args.push(entry.message);
|
||||
|
||||
// Structured payload — only appended when it has content.
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (entry.context) meta.context = entry.context;
|
||||
if (entry.tags?.length) meta.tags = entry.tags;
|
||||
if (entry.traceId) meta.traceId = entry.traceId;
|
||||
if (entry.durationMs !== undefined) meta.durationMs = entry.durationMs;
|
||||
if (entry.error) meta.error = entry.error;
|
||||
if (Object.keys(meta).length > 0) args.push(meta);
|
||||
|
||||
// Browser/Node console has no trace/fatal native —
|
||||
// TRACE → debug, FATAL → error.
|
||||
switch (entry.level) {
|
||||
case LogLevel.TRACE:
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(...(args as [unknown, ...unknown[]]));
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(...(args as [unknown, ...unknown[]]));
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(...(args as [unknown, ...unknown[]]));
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
case LogLevel.FATAL:
|
||||
console.error(...(args as [unknown, ...unknown[]]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST log entries to an HTTP endpoint. Fire-and-forget — network errors are
|
||||
* reported via `console.error` but do not interrupt the application flow.
|
||||
*
|
||||
* No built-in retry or batching. For high-volume needs use a custom transport
|
||||
* with a queue/retry layer.
|
||||
*/
|
||||
export function httpTransport(options: HttpTransportOptions): Transport {
|
||||
function serialize(entry: LogEntry): Record<string, unknown> {
|
||||
return {
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
level: entry.level,
|
||||
category: entry.category,
|
||||
message: entry.message,
|
||||
context: entry.context,
|
||||
error: entry.error,
|
||||
tags: entry.tags,
|
||||
traceId: entry.traceId,
|
||||
durationMs: entry.durationMs,
|
||||
source: entry.source
|
||||
};
|
||||
}
|
||||
|
||||
async function post(payload: unknown): Promise<void> {
|
||||
const res = await fetch(options.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`http transport push failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'http',
|
||||
minLevel: options.minLevel ?? LogLevel.ERROR,
|
||||
async write(entry: LogEntry): Promise<void> {
|
||||
await post(serialize(entry));
|
||||
},
|
||||
async writeBatch(entries: LogEntry[]): Promise<void> {
|
||||
// Batched delivery — single request with an array of entries.
|
||||
await post(entries.map(serialize));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CALLBACK TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transport that invokes a callback for each reaching entry. Structurally
|
||||
* equivalent to `logger.subscribe(fn)` — it exists as a factory so it can be
|
||||
* pre-registered via `LoggerOptions.transports` or composed with
|
||||
* `minLevel`/`filter`/`name`.
|
||||
*
|
||||
* @example
|
||||
* callbackTransport((entry) => captured.push(entry))
|
||||
*
|
||||
* @example
|
||||
* // Sentry integration: ERROR+ only, using the caller-provided SDK
|
||||
* callbackTransport(
|
||||
* (entry) => {
|
||||
* if (entry.error) Sentry.captureException(entry.error);
|
||||
* else Sentry.captureMessage(entry.message);
|
||||
* },
|
||||
* { minLevel: LogLevel.ERROR, name: 'sentry' }
|
||||
* )
|
||||
*/
|
||||
export function callbackTransport(
|
||||
fn: (entry: LogEntry) => void,
|
||||
options: { minLevel?: LogLevel; filter?: (entry: LogEntry) => boolean; name?: string } = {}
|
||||
): Transport {
|
||||
return {
|
||||
write: fn,
|
||||
minLevel: options.minLevel,
|
||||
filter: options.filter,
|
||||
name: options.name ?? 'callback'
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,302 @@
|
||||
// ============================================================================
|
||||
// LOG LEVEL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Severity levels, ordered low-to-high. A logger configured with level X
|
||||
* processes only entries whose level is >= X.
|
||||
*
|
||||
* Aligned with pino, Log4j, OpenTelemetry and Sentry's `Severity` enum.
|
||||
*
|
||||
* - **TRACE** — very verbose tracing (spans, frame-by-frame).
|
||||
* - **DEBUG** — development diagnostics.
|
||||
* - **INFO** — normal flow events worth noting.
|
||||
* - **WARN** — unexpected but non-interrupting situations.
|
||||
* - **ERROR** — recoverable errors that need attention.
|
||||
* - **FATAL** — unrecoverable failures (route to immediate paging).
|
||||
*/
|
||||
export enum LogLevel {
|
||||
TRACE = 0,
|
||||
DEBUG = 1,
|
||||
INFO = 2,
|
||||
WARN = 3,
|
||||
ERROR = 4,
|
||||
FATAL = 5,
|
||||
/** Disables every log. Deliberately high so any future level stays below. */
|
||||
NONE = 999
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CORE TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Functional domain of a log. Used as `[auth]` prefix in the console and as
|
||||
* a filter in `getLogs({ category })`.
|
||||
*/
|
||||
export type MessageCategory = string;
|
||||
|
||||
/**
|
||||
* Log message. Accepts a literal string or a thunk — the thunk is only
|
||||
* invoked if the entry passes the global level filter, avoiding formatting
|
||||
* cost for dropped logs.
|
||||
*
|
||||
* @example
|
||||
* logger.info('auth', 'Login succeeded');
|
||||
* logger.debug('db', () => `Query took ${performance.now() - t0}ms`);
|
||||
*/
|
||||
export type LogMessage = string | (() => string);
|
||||
|
||||
/**
|
||||
* Serializable shape of a captured error.
|
||||
*/
|
||||
export interface LogError {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source location where the log was emitted. Parsed from the native `Error`
|
||||
* stack. In minified production the names are mangled — disable
|
||||
* `captureSource` in that case.
|
||||
*/
|
||||
export interface LogSource {
|
||||
file?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
function?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional caller-provided data for a single log call.
|
||||
*/
|
||||
export interface LogInput {
|
||||
/** Structured diagnostic context. Merged with `globalContext` on the entry. */
|
||||
context?: Record<string, unknown>;
|
||||
/** Captured error. Extracted to `entry.error` as a structured `LogError`. */
|
||||
error?: unknown;
|
||||
/** Free-form tags for filtering — finer than `category`. */
|
||||
tags?: string[];
|
||||
/** Correlation id to trace async flows (fetch → handler → service). */
|
||||
traceId?: string;
|
||||
/** Duration in ms — auto-populated by `timeEnd()`. */
|
||||
durationMs?: number;
|
||||
/** Manual `source` override — useful for wrappers that re-emit logs. */
|
||||
source?: LogSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single history entry. `message` is already resolved to a string — the
|
||||
* logger knows nothing about i18n. For translated strings, resolve at the
|
||||
* call site: `logger.info('auth', lang.t('login.ok'))`.
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/**
|
||||
* Unique identifier (UUID v4 where available, hex fallback otherwise).
|
||||
* Survives serialization, enabling backend deduplication, partial-retry
|
||||
* in batched delivery, and cross-transport correlation.
|
||||
*/
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
category: MessageCategory;
|
||||
message: string;
|
||||
/** Logger `globalContext` merged with `input.context`. */
|
||||
context?: Record<string, unknown>;
|
||||
error?: LogError;
|
||||
tags?: string[];
|
||||
traceId?: string;
|
||||
durationMs?: number;
|
||||
source?: LogSource;
|
||||
/**
|
||||
* Names of transports that were skipped when dispatching this entry.
|
||||
* Auto-populated when a transport throws: the synthetic failure entry is
|
||||
* dispatched to the remaining transports with the failed one's name here,
|
||||
* for audit trail and to prevent loops (e.g. if Sentry fails, don't send
|
||||
* the Sentry-failure report to Sentry).
|
||||
*/
|
||||
deniedFor?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `getLogs()` filters. All optional — combined with AND.
|
||||
*/
|
||||
export interface LogFilters {
|
||||
level?: LogLevel;
|
||||
minLevel?: LogLevel;
|
||||
category?: MessageCategory;
|
||||
since?: Date;
|
||||
/** Matches when the entry's `tags` includes this value. */
|
||||
tag?: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRANSPORTS / SUBSCRIBERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Output destination for log entries.
|
||||
*
|
||||
* Can be registered at logger creation (`options.transports`) or dynamically
|
||||
* via `logger.addTransport()` / `logger.subscribe()`.
|
||||
*
|
||||
* Each transport decides whether to process each entry via `minLevel` and
|
||||
* `filter` — both applied after the logger's global level filter.
|
||||
*/
|
||||
export interface Transport {
|
||||
/**
|
||||
* Process a single entry. Sync or async.
|
||||
*
|
||||
* - If it throws synchronously: caught and a failure entry is dispatched
|
||||
* to the remaining transports with `deniedFor: [this.name]`.
|
||||
* - If it returns a rejecting `Promise`: same handler via `.catch`.
|
||||
* - The logger never awaits — dispatch is sync; async work inside the
|
||||
* transport is fire-and-forget from its own perspective.
|
||||
*/
|
||||
write(entry: LogEntry): void | Promise<void>;
|
||||
/**
|
||||
* Optional batch delivery. When the logger flushes a buffered transport
|
||||
* and `writeBatch` is defined, it is called once with all the queued
|
||||
* entries (instead of N separate `write` calls). Ideal for HTTP-style
|
||||
* sinks where one batched request is dramatically cheaper than many.
|
||||
*
|
||||
* If undefined, the logger falls back to `write` per entry.
|
||||
*/
|
||||
writeBatch?(entries: LogEntry[]): void | Promise<void>;
|
||||
/**
|
||||
* Number of entries to buffer before flushing. `0` (default) means
|
||||
* immediate delivery — every entry triggers a `write` call right away.
|
||||
*
|
||||
* When `buffer > 0`, entries are queued per-transport; the queue flushes
|
||||
* when it reaches `buffer` entries, or when `flushIntervalMs` elapses,
|
||||
* or when `logger.flush()` is called manually. In the browser, a
|
||||
* `beforeunload` handler flushes everything before the page is torn down.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
buffer?: number;
|
||||
/**
|
||||
* Flush the queue every N milliseconds even if `buffer` is not yet full.
|
||||
* Applies only when `buffer > 0`. @default 0 (no time-based flush).
|
||||
*/
|
||||
flushIntervalMs?: number;
|
||||
/**
|
||||
* Transport-level minimum severity. If undefined, only the logger's global
|
||||
* level applies. Useful to send only ERROR+ to Sentry while keeping the
|
||||
* logger at DEBUG.
|
||||
*/
|
||||
minLevel?: LogLevel;
|
||||
/**
|
||||
* Arbitrary predicate. If it returns `false`, the entry is skipped.
|
||||
* Useful to filter by category, tag, traceId, etc.
|
||||
*/
|
||||
filter?(entry: LogEntry): boolean;
|
||||
/**
|
||||
* Optional identifier for introspection and debugging.
|
||||
* Does not influence behavior.
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand form of a subscriber — no filters, just an observer on the stream.
|
||||
* Wrapped as a `Transport` internally on registration.
|
||||
*/
|
||||
export type SubscriberFn = (entry: LogEntry) => void;
|
||||
|
||||
export interface ConsoleTransportOptions {
|
||||
timestamp?: boolean;
|
||||
prefix?: boolean;
|
||||
/** Include the `source` in parentheses. @default true */
|
||||
source?: boolean;
|
||||
minLevel?: LogLevel;
|
||||
}
|
||||
|
||||
export interface HttpTransportOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
/** @default LogLevel.ERROR */
|
||||
minLevel?: LogLevel;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface LoggerOptions {
|
||||
/** @default LogLevel.WARN */
|
||||
level?: LogLevel;
|
||||
/** @default 1000 */
|
||||
maxLogs?: number;
|
||||
/** Transports registered at creation time. More can be added dynamically. */
|
||||
transports?: Transport[];
|
||||
/** Context merged into every entry. */
|
||||
globalContext?: Record<string, unknown>;
|
||||
/**
|
||||
* Capture `file:line` by parsing `new Error().stack` on every log.
|
||||
* Cost: ~0.1ms/log. Disable in minified production (names are mangled).
|
||||
* @default DEV
|
||||
*/
|
||||
captureSource?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INSTANCE TYPE
|
||||
// ============================================================================
|
||||
|
||||
export type LogFn = (
|
||||
category: MessageCategory,
|
||||
message: LogMessage,
|
||||
input?: LogInput
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Public `EngineLogger` interface.
|
||||
* Children created via `child()` share history, transports and level with
|
||||
* the parent — they only extend the `globalContext`.
|
||||
*/
|
||||
export interface EngineLogger {
|
||||
trace: LogFn;
|
||||
debug: LogFn;
|
||||
info: LogFn;
|
||||
warn: LogFn;
|
||||
error: LogFn;
|
||||
fatal: LogFn;
|
||||
|
||||
/** Defensive copy of the history, optionally filtered. */
|
||||
getLogs: (filters?: LogFilters) => LogEntry[];
|
||||
/** Empty the history. Shared with children. */
|
||||
clear: () => void;
|
||||
/** Export the history as JSON. */
|
||||
serialize: () => string;
|
||||
|
||||
setLevel: (level: LogLevel) => void;
|
||||
setMaxLogs: (max: number) => void;
|
||||
/** Replace the logger's `globalContext`. Does not affect previously created children. */
|
||||
setGlobalContext: (ctx: Record<string, unknown>) => void;
|
||||
|
||||
/** Register a transport at runtime. Returns a function that detaches it. */
|
||||
addTransport: (transport: Transport) => () => void;
|
||||
/** Sugar: equivalent to `addTransport({ write: fn })`. */
|
||||
subscribe: (fn: SubscriberFn) => () => void;
|
||||
/** Remove every transport. Handy in tests. */
|
||||
removeAllTransports: () => void;
|
||||
/** Read-only snapshot of active transports. */
|
||||
transports: () => readonly Transport[];
|
||||
|
||||
/** Child logger with extended `globalContext`. Shares history and transports. */
|
||||
child: (ctx: Record<string, unknown>) => EngineLogger;
|
||||
|
||||
time: (label: string) => void;
|
||||
timeEnd: (label: string, category?: MessageCategory, message?: LogMessage) => void;
|
||||
|
||||
/**
|
||||
* Flush every buffered transport synchronously. Use before handing control
|
||||
* back to the host (page unload, test teardown, app exit) to make sure
|
||||
* queued entries are delivered.
|
||||
*/
|
||||
flush: () => void;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>active</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>active</h1>
|
||||
<p>
|
||||
<a href={resolve('/test')}>→ páginas de prueba</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 600px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tests — arts</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>Páginas de prueba</h1>
|
||||
<p>Demostraciones interactivas de los módulos de <code>src/arts</code>.</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href={resolve('/test/lang')}>/test/lang</a> — cambio de idioma reactivo (es/en/ar) sobre
|
||||
distintos elementos UI: títulos, navegación, formularios, plurales, interpolación y alias.
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/test/logr')}>/test/logr</a> — logger acoplado a lang: entradas en distintos
|
||||
niveles, historial con locale capturado al registrar, serialización y cambio de nivel.
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
a {
|
||||
color: #0366d6;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import type { SupportedLocale } from '$lang';
|
||||
import { createActiveLang } from '$lang/active-lang.svelte';
|
||||
import { translations } from './schema';
|
||||
|
||||
const lang = createActiveLang(translations, 'es');
|
||||
|
||||
const LOCALES: { code: SupportedLocale; label: string; flag: string }[] = [
|
||||
{ code: 'es', label: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ code: 'ar', label: 'العربية', flag: '🇸🇦' }
|
||||
];
|
||||
|
||||
let userName = $state('Ana');
|
||||
let cartCount = $state(1);
|
||||
|
||||
const isRTL = $derived(lang.getLocale() === 'ar');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>/test/lang</title>
|
||||
</svelte:head>
|
||||
|
||||
<main dir={isRTL ? 'rtl' : 'ltr'} class:rtl={isRTL}>
|
||||
<header>
|
||||
<h1>{lang.t('meta.title')}</h1>
|
||||
<p class="subtitle">{lang.t('meta.subtitle')}</p>
|
||||
|
||||
<div class="locale-switcher" dir="ltr">
|
||||
<strong>Locale:</strong>
|
||||
{#each LOCALES as { code, label, flag } (code)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={lang.getLocale() === code}
|
||||
onclick={() => lang.setLocale(code)}
|
||||
>
|
||||
<span aria-hidden="true">{flag}</span>
|
||||
{label}
|
||||
<code>({code})</code>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Navegación</h2>
|
||||
<nav>
|
||||
<a href="#home">{lang.t('nav.home')}</a>
|
||||
<a href="#products">{lang.t('nav.products')}</a>
|
||||
<a href="#about">{lang.t('nav.about')}</a>
|
||||
<a href="#contact">{lang.t('nav.contact')}</a>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Interpolación</h2>
|
||||
<label>
|
||||
<span>{lang.t('form.name')}:</span>
|
||||
<input type="text" bind:value={userName} placeholder={lang.t('form.placeholder')} />
|
||||
</label>
|
||||
<p class="greeting">{lang.t('greet', { name: userName })}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Pluralización (Intl.PluralRules)</h2>
|
||||
<label>
|
||||
<span>Count:</span>
|
||||
<input type="number" min="0" max="99" bind:value={cartCount} />
|
||||
<input type="range" min="0" max="20" bind:value={cartCount} />
|
||||
</label>
|
||||
<p class="plural">{lang.t('cart.itemsInCart', { count: cartCount })}</p>
|
||||
<p class="hint">
|
||||
En árabe se aplican las 6 formas plurales de <code>Intl.PluralRules</code>: zero, one, two,
|
||||
few, many, other.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Formulario</h2>
|
||||
<form onsubmit={(e) => e.preventDefault()}>
|
||||
<label>
|
||||
<span>{lang.t('form.email')}</span>
|
||||
<input type="email" placeholder={lang.t('form.placeholder')} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{lang.t('form.message')}</span>
|
||||
<textarea rows="3" placeholder={lang.t('form.placeholder')}></textarea>
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="primary">{lang.t('form.send')}</button>
|
||||
<button type="button">{lang.t('actions.save')}</button>
|
||||
<button type="button">{lang.t('actions.confirm')}</button>
|
||||
<button type="button" class="danger">{lang.t('actions.delete')}</button>
|
||||
<button type="reset">{lang.t('actions.cancel')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Alias (LangRef)</h2>
|
||||
<p>
|
||||
<code>actions.acceptAlias</code> es un alias <code>#?actions.confirm</code>:
|
||||
<strong>{lang.t('actions.acceptAlias')}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Mensajes de error</h2>
|
||||
<div class="errors">
|
||||
<div class="error">{lang.t('errors.notFound')}</div>
|
||||
<div class="error">{lang.t('errors.network')}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Uso directo con <code>ts()</code></h2>
|
||||
<p>
|
||||
Record inline: <em>{lang.ts({ es: 'Valor directo', en: 'Direct value', ar: 'قيمة مباشرة' })}</em>
|
||||
</p>
|
||||
<p>
|
||||
LangRef con fallback literal:
|
||||
<em>{lang.ts('#?no.existe|Fallback text')}</em>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 820px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #222;
|
||||
}
|
||||
main.rtl {
|
||||
font-family: 'Segoe UI', 'Tahoma', system-ui, sans-serif;
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
h2 {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.3rem;
|
||||
margin-top: 2rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-top: 0;
|
||||
}
|
||||
.locale-switcher {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.locale-switcher button {
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5da;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.locale-switcher button.active {
|
||||
background: #0366d6;
|
||||
color: #fff;
|
||||
border-color: #0366d6;
|
||||
}
|
||||
.locale-switcher button:hover:not(.active) {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav a {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: #f1f8ff;
|
||||
}
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
label span {
|
||||
display: inline-block;
|
||||
min-width: 160px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='number'],
|
||||
textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid #d1d5da;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
input[type='range'] {
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
resize: vertical;
|
||||
}
|
||||
.greeting,
|
||||
.plural {
|
||||
background: #f6f8fa;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button[type='button'],
|
||||
button[type='submit'],
|
||||
button[type='reset'] {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #d1d5da;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
button.primary {
|
||||
background: #2ea44f;
|
||||
color: #fff;
|
||||
border-color: #2a8644;
|
||||
}
|
||||
button.danger {
|
||||
background: #d73a49;
|
||||
color: #fff;
|
||||
border-color: #b82d3a;
|
||||
}
|
||||
.errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.error {
|
||||
background: #ffeef0;
|
||||
color: #86181d;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #d73a49;
|
||||
}
|
||||
main.rtl .error {
|
||||
border-left: none;
|
||||
border-right: 3px solid #d73a49;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,79 @@
|
||||
import { p, type LangNode } from '$lang';
|
||||
|
||||
export const translations = {
|
||||
meta: {
|
||||
title: {
|
||||
es: 'Demostración del módulo lang',
|
||||
en: 'lang module demo',
|
||||
ar: 'عرض توضيحي لوحدة lang'
|
||||
},
|
||||
subtitle: {
|
||||
es: 'Traducciones reactivas con español, inglés y árabe',
|
||||
en: 'Reactive translations in Spanish, English and Arabic',
|
||||
ar: 'ترجمات تفاعلية بالإسبانية والإنجليزية والعربية'
|
||||
}
|
||||
},
|
||||
nav: {
|
||||
home: { es: 'Inicio', en: 'Home', ar: 'الرئيسية' },
|
||||
products: { es: 'Productos', en: 'Products', ar: 'المنتجات' },
|
||||
about: { es: 'Acerca de', en: 'About', ar: 'حول' },
|
||||
contact: { es: 'Contacto', en: 'Contact', ar: 'اتصل بنا' }
|
||||
},
|
||||
actions: {
|
||||
save: { es: 'Guardar', en: 'Save', ar: 'حفظ' },
|
||||
cancel: { es: 'Cancelar', en: 'Cancel', ar: 'إلغاء' },
|
||||
delete: { es: 'Eliminar', en: 'Delete', ar: 'حذف' },
|
||||
confirm: { es: 'Confirmar', en: 'Confirm', ar: 'تأكيد' },
|
||||
acceptAlias: '#?actions.confirm'
|
||||
},
|
||||
form: {
|
||||
name: { es: 'Nombre', en: 'Name', ar: 'الاسم' },
|
||||
email: { es: 'Correo electrónico', en: 'Email', ar: 'البريد الإلكتروني' },
|
||||
message: { es: 'Mensaje', en: 'Message', ar: 'الرسالة' },
|
||||
send: { es: 'Enviar', en: 'Send', ar: 'إرسال' },
|
||||
placeholder: {
|
||||
es: 'Escribe aquí...',
|
||||
en: 'Type here...',
|
||||
ar: 'اكتب هنا...'
|
||||
}
|
||||
},
|
||||
greet: (params: { name: string }) => ({
|
||||
es: `¡Hola, ${params.name}! Bienvenido.`,
|
||||
en: `Hello, ${params.name}! Welcome.`,
|
||||
ar: `مرحباً، ${params.name}! أهلاً وسهلاً.`
|
||||
}),
|
||||
cart: {
|
||||
itemsInCart: p({
|
||||
es: {
|
||||
one: 'Tienes {{count}} producto en el carrito',
|
||||
other: 'Tienes {{count}} productos en el carrito'
|
||||
},
|
||||
en: {
|
||||
one: 'You have {{count}} item in your cart',
|
||||
other: 'You have {{count}} items in your cart'
|
||||
},
|
||||
ar: {
|
||||
zero: 'لا توجد منتجات في سلتك',
|
||||
one: 'لديك منتج واحد في سلتك',
|
||||
two: 'لديك منتجان في سلتك',
|
||||
few: 'لديك {{count}} منتجات في سلتك',
|
||||
many: 'لديك {{count}} منتجاً في سلتك',
|
||||
other: 'لديك {{count}} منتج في سلتك'
|
||||
}
|
||||
})
|
||||
},
|
||||
errors: {
|
||||
notFound: {
|
||||
es: 'Página no encontrada',
|
||||
en: 'Page not found',
|
||||
ar: 'الصفحة غير موجودة'
|
||||
},
|
||||
network: {
|
||||
es: 'Error de conexión',
|
||||
en: 'Network error',
|
||||
ar: 'خطأ في الشبكة'
|
||||
}
|
||||
}
|
||||
} satisfies LangNode;
|
||||
|
||||
export type TranslationSchema = typeof translations;
|
||||
@ -0,0 +1,917 @@
|
||||
<script lang="ts">
|
||||
import type { LogEntry, Transport } from '$logr';
|
||||
import {
|
||||
LogLevel,
|
||||
callbackTransport,
|
||||
consoleTransport,
|
||||
createEngineLogger
|
||||
} from '$logr';
|
||||
import { sentryTransport } from '$logr/adapters/sentry';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Capturamos las entries localmente para mostrarlas en una tabla.
|
||||
const captured: LogEntry[] = $state([]);
|
||||
const captureTransport: Transport = {
|
||||
name: 'ui-capture',
|
||||
write(entry) {
|
||||
captured.push(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const logger = createEngineLogger({
|
||||
level: LogLevel.DEBUG,
|
||||
captureSource: true,
|
||||
globalContext: {
|
||||
appVersion: '1.0.0',
|
||||
env: 'test',
|
||||
sessionId: 'sess-' + Math.random().toString(36).slice(2, 8)
|
||||
},
|
||||
transports: [consoleTransport({ minLevel: LogLevel.DEBUG }), captureTransport]
|
||||
});
|
||||
|
||||
const LEVELS = [
|
||||
{ name: 'TRACE', level: LogLevel.TRACE, color: '#8a8f96' },
|
||||
{ name: 'DEBUG', level: LogLevel.DEBUG, color: '#6a737d' },
|
||||
{ name: 'INFO', level: LogLevel.INFO, color: '#0366d6' },
|
||||
{ name: 'WARN', level: LogLevel.WARN, color: '#b08800' },
|
||||
{ name: 'ERROR', level: LogLevel.ERROR, color: '#d73a49' },
|
||||
{ name: 'FATAL', level: LogLevel.FATAL, color: '#6f42c1' }
|
||||
] as const;
|
||||
|
||||
let currentMinLevel = $state(LogLevel.TRACE);
|
||||
let counter = $state(1);
|
||||
let traceIdFilter = $state('');
|
||||
|
||||
// Referencias a transports dinámicos para poder mostrarlos / removerlos.
|
||||
const attached: { name: string; remove: () => void }[] = $state([]);
|
||||
// Forzamos un contador para refrescar la lista de transports (readonly array).
|
||||
let transportsVersion = $state(0);
|
||||
|
||||
function refreshTransports(): void {
|
||||
transportsVersion++;
|
||||
}
|
||||
|
||||
function levelName(l: LogLevel): string {
|
||||
return LogLevel[l] ?? String(l);
|
||||
}
|
||||
|
||||
function logSample(level: LogLevel): void {
|
||||
const message = `Test event #${counter}`;
|
||||
const fns = {
|
||||
[LogLevel.TRACE]: logger.trace,
|
||||
[LogLevel.DEBUG]: logger.debug,
|
||||
[LogLevel.INFO]: logger.info,
|
||||
[LogLevel.WARN]: logger.warn,
|
||||
[LogLevel.ERROR]: logger.error,
|
||||
[LogLevel.FATAL]: logger.fatal
|
||||
} as const;
|
||||
fns[level as keyof typeof fns]?.('demo', message, {
|
||||
context: { n: counter },
|
||||
tags: ['demo', `counter-${counter % 3}`],
|
||||
traceId: `trace-${Math.floor(counter / 3)}`
|
||||
});
|
||||
counter++;
|
||||
}
|
||||
|
||||
function logWithError(): void {
|
||||
try {
|
||||
JSON.parse('{ not valid json }');
|
||||
} catch (err) {
|
||||
logger.error('parse', 'JSON parse error', {
|
||||
error: err,
|
||||
tags: ['parse', 'critical'],
|
||||
context: { input: '{ not valid json }' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function logWithChildContext(): void {
|
||||
const requestLogger = logger.child({
|
||||
requestId: 'req-' + Math.random().toString(36).slice(2, 6),
|
||||
userId: 42
|
||||
});
|
||||
requestLogger.info('auth', 'Authenticated request');
|
||||
requestLogger.warn('auth', 'Token near expiry');
|
||||
}
|
||||
|
||||
function logWithLazyMessage(): void {
|
||||
// El thunk solo se evalúa si supera el filtro de nivel global.
|
||||
logger.debug('perf', () => {
|
||||
const t0 = performance.now();
|
||||
// Simular formatting caro
|
||||
const data = Array.from({ length: 1000 }, (_, i) => i).reduce((a, b) => a + b, 0);
|
||||
return `Lazy-computed sum=${data} in ${(performance.now() - t0).toFixed(2)}ms`;
|
||||
});
|
||||
}
|
||||
|
||||
async function logWithTimer(): Promise<void> {
|
||||
logger.time('fake-request');
|
||||
await new Promise((resolve) => setTimeout(resolve, 150 + Math.random() * 250));
|
||||
logger.timeEnd('fake-request', 'perf', 'Simulated request completed');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Demo: transports dinámicos
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function attachFakeSentry(): void {
|
||||
const fakeSentry: Transport = {
|
||||
name: 'fake-sentry',
|
||||
minLevel: LogLevel.ERROR,
|
||||
write(entry) {
|
||||
console.log('[fake-sentry] captureException/Message', {
|
||||
message: entry.message,
|
||||
error: entry.error?.message,
|
||||
tags: entry.tags
|
||||
});
|
||||
}
|
||||
};
|
||||
const remove = logger.addTransport(fakeSentry);
|
||||
attached.push({ name: fakeSentry.name!, remove });
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
function attachMetricsSubscriber(): void {
|
||||
let errorCount = 0;
|
||||
const unsub = logger.subscribe((entry) => {
|
||||
if (entry.level >= LogLevel.ERROR) {
|
||||
errorCount++;
|
||||
console.log(`[metrics] errorCount=${errorCount}`, { category: entry.category });
|
||||
}
|
||||
});
|
||||
attached.push({ name: 'metrics (subscriber)', remove: unsub });
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
function attachSyncFailingTransport(): void {
|
||||
const failing: Transport = {
|
||||
name: 'sync-fail',
|
||||
write() {
|
||||
throw new Error('Simulated sync transport failure');
|
||||
}
|
||||
};
|
||||
const remove = logger.addTransport(failing);
|
||||
attached.push({ name: failing.name!, remove });
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
function attachAsyncFailingTransport(): void {
|
||||
const failing: Transport = {
|
||||
name: 'async-fail',
|
||||
async write() {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
throw new Error('Simulated async transport rejection');
|
||||
}
|
||||
};
|
||||
const remove = logger.addTransport(failing);
|
||||
attached.push({ name: failing.name!, remove });
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
function detach(idx: number): void {
|
||||
attached[idx].remove();
|
||||
attached.splice(idx, 1);
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sentry integration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const DSN_KEY = 'logr-test-sentry-dsn';
|
||||
let sentryDsn = $state(browser ? sessionStorage.getItem(DSN_KEY) ?? '' : '');
|
||||
let sentryStatus = $state<'idle' | 'initializing' | 'ready' | 'error'>('idle');
|
||||
let sentryErrorMsg = $state('');
|
||||
let sentryAttached = $state(false);
|
||||
let sentryRemove: (() => void) | null = null;
|
||||
|
||||
function persistDsn(): void {
|
||||
if (browser) sessionStorage.setItem(DSN_KEY, sentryDsn);
|
||||
}
|
||||
|
||||
async function initSentry(): Promise<void> {
|
||||
if (!sentryDsn.trim()) {
|
||||
sentryStatus = 'error';
|
||||
sentryErrorMsg = 'Pegá una DSN válida';
|
||||
return;
|
||||
}
|
||||
sentryStatus = 'initializing';
|
||||
try {
|
||||
const Sentry = await import('@sentry/browser');
|
||||
Sentry.init({
|
||||
dsn: sentryDsn.trim(),
|
||||
environment: 'logr-test',
|
||||
release: 'logr-test@1.0.0',
|
||||
sampleRate: 1.0,
|
||||
tracesSampleRate: 0
|
||||
});
|
||||
persistDsn();
|
||||
sentryStatus = 'ready';
|
||||
sentryErrorMsg = '';
|
||||
} catch (err) {
|
||||
sentryStatus = 'error';
|
||||
sentryErrorMsg = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachSentry(): Promise<void> {
|
||||
if (sentryStatus !== 'ready') return;
|
||||
const Sentry = await import('@sentry/browser');
|
||||
sentryRemove = logger.addTransport(
|
||||
sentryTransport(Sentry, {
|
||||
minLevel: LogLevel.ERROR,
|
||||
breadcrumbLevel: LogLevel.INFO,
|
||||
name: 'sentry'
|
||||
})
|
||||
);
|
||||
sentryAttached = true;
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
function detachSentry(): void {
|
||||
sentryRemove?.();
|
||||
sentryRemove = null;
|
||||
sentryAttached = false;
|
||||
refreshTransports();
|
||||
}
|
||||
|
||||
async function sendTestToSentry(): Promise<void> {
|
||||
// Preparamos breadcrumbs
|
||||
logger.info('sentry-test', 'Preparing Sentry test', {
|
||||
context: { step: 1 }
|
||||
});
|
||||
logger.warn('sentry-test', 'About to trigger test error', {
|
||||
context: { step: 2 },
|
||||
tags: ['pre-flight']
|
||||
});
|
||||
// Evento real
|
||||
try {
|
||||
throw new Error('Logr → Sentry test: ' + new Date().toISOString());
|
||||
} catch (err) {
|
||||
logger.error('sentry-test', 'Test error captured', {
|
||||
error: err,
|
||||
traceId: 'sentry-test-' + Math.random().toString(36).slice(2, 8),
|
||||
tags: ['sentry-test', 'demo'],
|
||||
context: { intentional: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function changeLevel(l: LogLevel): void {
|
||||
currentMinLevel = l;
|
||||
logger.setLevel(l);
|
||||
}
|
||||
|
||||
function clearHistory(): void {
|
||||
logger.clear();
|
||||
captured.splice(0, captured.length);
|
||||
}
|
||||
|
||||
function downloadSerialized(): void {
|
||||
const json = logger.serialize();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `logr-${new Date().toISOString()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const filtered = $derived(
|
||||
traceIdFilter ? captured.filter((e) => e.traceId === traceIdFilter) : captured
|
||||
);
|
||||
|
||||
const stats = $derived({
|
||||
total: captured.length,
|
||||
byLevel: LEVELS.map((l) => ({
|
||||
...l,
|
||||
count: captured.filter((e) => e.level === l.level).length
|
||||
}))
|
||||
});
|
||||
|
||||
const activeTransports = $derived.by(() => {
|
||||
void transportsVersion; // fuerza re-evaluación al cambiar lista
|
||||
return logger.transports();
|
||||
});
|
||||
|
||||
// Usado solo en el efecto callback de captureSubscriber (no aplica aquí).
|
||||
// Removemos callbackTransport del import si no se usa.
|
||||
void callbackTransport;
|
||||
|
||||
function fileShort(file?: string): string {
|
||||
if (!file) return '';
|
||||
const idx = file.lastIndexOf('/');
|
||||
const short = idx >= 0 ? file.slice(idx + 1) : file;
|
||||
return short.split('?')[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>/test/logr</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>Prueba del módulo logr</h1>
|
||||
<p class="subtitle">
|
||||
Logger profesional, <strong>desacoplado de i18n</strong>. Transports dinámicos con filtros
|
||||
per-sink, dispatch sync con soporte async, <code>deniedFor</code> en fallos,
|
||||
<code>globalContext</code>, <code>child()</code>, <code>time()</code>, mensajes lazy,
|
||||
<code>source</code> auto-capturado, error estructurado, <code>traceId</code>,
|
||||
<code>tags</code>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="controls">
|
||||
<div class="group">
|
||||
<h3>Nivel mínimo</h3>
|
||||
<div class="btn-row">
|
||||
{#each LEVELS as { name, level } (level)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={currentMinLevel === level}
|
||||
onclick={() => changeLevel(level)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="hint">Actual: <strong>{levelName(currentMinLevel)}</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h3>Emitir básico</h3>
|
||||
<div class="btn-row">
|
||||
{#each LEVELS as { name, level, color } (level)}
|
||||
<button
|
||||
type="button"
|
||||
style:background={color}
|
||||
style:color="#fff"
|
||||
onclick={() => logSample(level)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="hint">Cada 3 logs comparten <code>traceId</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h3>Features</h3>
|
||||
<div class="btn-row">
|
||||
<button type="button" onclick={logWithError}>error + Error</button>
|
||||
<button type="button" onclick={logWithChildContext}>child(ctx)</button>
|
||||
<button type="button" onclick={logWithTimer}>time/timeEnd</button>
|
||||
<button type="button" onclick={logWithLazyMessage}>lazy msg</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h3>Historial</h3>
|
||||
<div class="btn-row">
|
||||
<button type="button" onclick={clearHistory}>Vaciar</button>
|
||||
<button type="button" onclick={downloadSerialized}>Descargar JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sentry">
|
||||
<h2>Integración con Sentry</h2>
|
||||
<p class="hint">
|
||||
Pegá la DSN de tu proyecto Sentry (se guarda en <code>sessionStorage</code>). El SDK se
|
||||
importa lazy para no cargarlo si no se usa.
|
||||
</p>
|
||||
<div class="sentry-form">
|
||||
<label>
|
||||
<span>DSN:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={sentryDsn}
|
||||
placeholder="https://xxxx@xxxx.ingest.sentry.io/xxxx"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
onclick={initSentry}
|
||||
disabled={sentryStatus === 'initializing' || sentryStatus === 'ready'}
|
||||
>
|
||||
{sentryStatus === 'ready' ? '✓ Sentry inicializado' : 'Inicializar Sentry'}
|
||||
</button>
|
||||
{#if sentryStatus === 'ready' && !sentryAttached}
|
||||
<button type="button" onclick={attachSentry} class="primary">
|
||||
Attach sentryTransport (ERROR+ event, INFO/WARN breadcrumb)
|
||||
</button>
|
||||
{/if}
|
||||
{#if sentryAttached}
|
||||
<button type="button" onclick={detachSentry} class="remove-btn">Detach Sentry</button>
|
||||
<button type="button" onclick={sendTestToSentry} class="primary">
|
||||
Enviar test (2 breadcrumbs + 1 event)
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sentryStatus === 'error'}
|
||||
<p class="error-msg">Error: {sentryErrorMsg}</p>
|
||||
{/if}
|
||||
{#if sentryAttached}
|
||||
<p class="hint success">
|
||||
✓ Sentry attached como transport. Ahora cualquier log ≥ ERROR irá al dashboard de Sentry;
|
||||
INFO y WARN como breadcrumbs del próximo error.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Transports activos ({activeTransports.length})</h2>
|
||||
<div class="transports">
|
||||
{#each activeTransports as t, i (i)}
|
||||
<div class="transport">
|
||||
<strong>{t.name ?? 'anonymous'}</strong>
|
||||
{#if t.minLevel !== undefined}
|
||||
<span class="badge">minLevel: {levelName(t.minLevel)}</span>
|
||||
{/if}
|
||||
{#if t.filter}
|
||||
<span class="badge">filter</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="attach-title">Añadir dinámicamente:</h3>
|
||||
<div class="btn-row">
|
||||
<button type="button" onclick={attachFakeSentry}>+ fake-sentry (ERROR+)</button>
|
||||
<button type="button" onclick={attachMetricsSubscriber}>+ metrics (subscriber)</button>
|
||||
<button type="button" onclick={attachSyncFailingTransport}>+ sync-fail</button>
|
||||
<button type="button" onclick={attachAsyncFailingTransport}>+ async-fail</button>
|
||||
</div>
|
||||
|
||||
{#if attached.length > 0}
|
||||
<h3 class="attach-title">Attached runtime:</h3>
|
||||
<div class="attached">
|
||||
{#each attached as a, i (i)}
|
||||
<div class="attached-item">
|
||||
<code>{a.name}</code>
|
||||
<button type="button" class="remove" onclick={() => detach(i)}>Quitar</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Emite un log de nivel ≥ ERROR con el <code>sync-fail</code> o <code>async-fail</code>
|
||||
adjunto y observá cómo aparece una entry <code>[logr]</code> con
|
||||
<code>deniedFor: ["sync-fail"]</code> — el reporte del fallo NO se vuelve a enviar
|
||||
al transport que falló.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Estadísticas</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><strong>{stats.total}</strong><span>Total</span></div>
|
||||
{#each stats.byLevel as { name, count, color } (name)}
|
||||
<div class="stat" style:border-color={color}>
|
||||
<strong style:color>{count}</strong><span>{name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Filtro por traceId</h2>
|
||||
<label class="filter">
|
||||
<span>traceId:</span>
|
||||
<input type="text" bind:value={traceIdFilter} placeholder="trace-0" />
|
||||
{#if traceIdFilter}
|
||||
<button type="button" onclick={() => (traceIdFilter = '')}>Limpiar</button>
|
||||
{/if}
|
||||
<span class="hint-inline">
|
||||
({filtered.length} de {captured.length} coinciden)
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Historial ({filtered.length})</h2>
|
||||
{#if filtered.length === 0}
|
||||
<p class="empty">Sin entradas. Usá los botones de arriba.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nivel</th>
|
||||
<th>Cat</th>
|
||||
<th>Mensaje</th>
|
||||
<th>traceId</th>
|
||||
<th>tags</th>
|
||||
<th>deniedFor</th>
|
||||
<th>source</th>
|
||||
<th>dur</th>
|
||||
<th>context</th>
|
||||
<th>error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as entry, i (i)}
|
||||
<tr class:failure={entry.deniedFor !== undefined}>
|
||||
<td>{i + 1}</td>
|
||||
<td>
|
||||
<span
|
||||
class="level"
|
||||
style:background={LEVELS.find((l) => l.level === entry.level)?.color}
|
||||
>
|
||||
{levelName(entry.level)}
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{entry.category}</code></td>
|
||||
<td>{entry.message}</td>
|
||||
<td>
|
||||
{#if entry.traceId}
|
||||
<code class="trace">{entry.traceId}</code>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.tags?.length}
|
||||
{#each entry.tags as t (t)}
|
||||
<span class="tag">{t}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.deniedFor?.length}
|
||||
{#each entry.deniedFor as d (d)}
|
||||
<span class="denied">{d}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.source}
|
||||
<code class="source" title={entry.source.file}
|
||||
>{fileShort(entry.source.file)}:{entry.source.line}</code
|
||||
>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.durationMs !== undefined}
|
||||
<code>{entry.durationMs.toFixed(1)}ms</code>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.context}
|
||||
<code class="ctx">{JSON.stringify(entry.context)}</code>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if entry.error}
|
||||
<details>
|
||||
<summary><strong>{entry.error.name}</strong>: {entry.error.message}</summary>
|
||||
{#if entry.error.stack}
|
||||
<pre class="stack">{entry.error.stack}</pre>
|
||||
{/if}
|
||||
</details>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #222;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
h2 {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.3rem;
|
||||
margin-top: 2rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #586069;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
background: #f6f8fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-row button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #d1d5da;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-row button.active {
|
||||
background: #0366d6;
|
||||
color: #fff;
|
||||
border-color: #0366d6;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.hint-inline {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.transports {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.transport {
|
||||
background: #f0fff4;
|
||||
border: 1px solid #bef5cb;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #e1ecf4;
|
||||
color: #39739d;
|
||||
padding: 0.05em 0.4em;
|
||||
margin-left: 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.attach-title {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.attached {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.attached-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.remove {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #d73a49;
|
||||
background: #fff;
|
||||
color: #d73a49;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sentry {
|
||||
background: #fff5f7;
|
||||
border: 1px solid #f0b3c0;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.sentry h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sentry-form {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.sentry-form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.sentry-form label span {
|
||||
font-weight: 600;
|
||||
min-width: 3rem;
|
||||
}
|
||||
.sentry-form input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid #d1d5da;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sentry-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sentry-form button.primary {
|
||||
background: #6f42c1;
|
||||
color: #fff;
|
||||
border-color: #553098;
|
||||
}
|
||||
.sentry-form button.remove-btn {
|
||||
background: #fff;
|
||||
color: #d73a49;
|
||||
border-color: #d73a49;
|
||||
}
|
||||
.error-msg {
|
||||
background: #ffeef0;
|
||||
color: #86181d;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.hint.success {
|
||||
color: #22863a;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
border: 2px solid #e1e4e8;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.stat span {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #d1d5da;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter button {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d1d5da;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f6f8fa;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #586069;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tr.failure {
|
||||
background: #fff5f5;
|
||||
}
|
||||
.level {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #bbb;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #e1ecf4;
|
||||
color: #39739d;
|
||||
padding: 0.05em 0.4em;
|
||||
margin: 0 0.15em 0.15em 0;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.denied {
|
||||
display: inline-block;
|
||||
background: #ffeef0;
|
||||
color: #86181d;
|
||||
padding: 0.05em 0.4em;
|
||||
margin: 0 0.15em 0.15em 0;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trace {
|
||||
color: #6f42c1;
|
||||
background: #f5f0ff;
|
||||
}
|
||||
.source {
|
||||
color: #22863a;
|
||||
background: #f0fff4;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ctx {
|
||||
font-size: 0.72rem;
|
||||
max-width: 260px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.stack {
|
||||
font-size: 0.7rem;
|
||||
background: #fff5f5;
|
||||
color: #86181d;
|
||||
padding: 0.4rem;
|
||||
margin-top: 0.3rem;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
max-width: 300px;
|
||||
}
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
files : {
|
||||
routes: 'src/web/routes',
|
||||
},
|
||||
alias: {
|
||||
$lang: 'src/arts/lang',
|
||||
$logr: 'src/arts/logr',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium', headless: true }]
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**']
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
Loading…
Reference in new issue