@refraction-ui/astro 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics/analytics-manager.ts +361 -0
- package/dist/analytics/consent.ts +40 -0
- package/dist/analytics/console-sink.ts +37 -0
- package/dist/analytics/http-sink.ts +213 -0
- package/dist/analytics/identity.ts +64 -0
- package/dist/analytics/index.ts +60 -0
- package/dist/analytics/mock-sink.ts +62 -0
- package/dist/analytics/noop.ts +48 -0
- package/dist/analytics/redaction.ts +93 -0
- package/dist/analytics/session.ts +162 -0
- package/dist/analytics/storage.ts +119 -0
- package/dist/analytics/types.ts +273 -0
- package/dist/analytics/uuid.ts +63 -0
- package/dist/astro-analytics/AnalyticsScript.astro +130 -0
- package/dist/astro-analytics/index.ts +49 -0
- package/dist/astro-logger/TelemetryScript.astro +108 -0
- package/dist/astro-logger/index.ts +48 -0
- package/dist/astro-logger/library-error-capture.ts +121 -0
- package/dist/astro-logger/telemetry-middleware.ts +92 -0
- package/dist/index.ts +2 -0
- package/dist/logger/console-sink.ts +64 -0
- package/dist/logger/faro-engine.ts +130 -0
- package/dist/logger/index.ts +39 -0
- package/dist/logger/mock-sink.ts +38 -0
- package/dist/logger/noop.ts +52 -0
- package/dist/logger/presets.ts +51 -0
- package/dist/logger/redact.ts +33 -0
- package/dist/logger/telemetry-manager.ts +229 -0
- package/dist/logger/types.ts +121 -0
- package/dist/shared/dev-feedback.ts +154 -0
- package/dist/shared/index.ts +25 -0
- package/dist/shared/library-origin-error.ts +240 -0
- package/package.json +4 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createConsoleSink } from './console-sink.js'
|
|
2
|
+
import { createNoopTelemetry } from './noop.js'
|
|
3
|
+
import { resolvePreset, type TelemetryPreset } from './presets.js'
|
|
4
|
+
import { redact } from './redact.js'
|
|
5
|
+
import {
|
|
6
|
+
LEVEL_ORDER,
|
|
7
|
+
type LogContext,
|
|
8
|
+
type LogLevel,
|
|
9
|
+
type LogRecord,
|
|
10
|
+
type Span,
|
|
11
|
+
type SpanRecord,
|
|
12
|
+
type Telemetry,
|
|
13
|
+
type TelemetryConfig,
|
|
14
|
+
type TelemetrySink,
|
|
15
|
+
} from './types.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* createTelemetry — creates a telemetry manager that fans records out to
|
|
19
|
+
* registered sinks. Manager/provider pattern, mirroring `createAI`.
|
|
20
|
+
*
|
|
21
|
+
* - `enabled: false` -> tree-shakeable noop (zero emissions, no engines).
|
|
22
|
+
* - no `endpoint` -> console-only transport.
|
|
23
|
+
* - `endpoint` set -> async Faro engine is registered when the optional
|
|
24
|
+
* peers exist; console stays as a safe fallback until then.
|
|
25
|
+
*
|
|
26
|
+
* The returned object IS a logger (root context = `{}`); `child()` derives
|
|
27
|
+
* loggers with bound context (sessionId / interviewId / turnId / ...).
|
|
28
|
+
*/
|
|
29
|
+
export function createTelemetry(config: TelemetryConfig): Telemetry {
|
|
30
|
+
if (config.enabled === false) {
|
|
31
|
+
return createNoopTelemetry()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const preset = resolvePreset(config.env)
|
|
35
|
+
const sampleRate = config.sampleRate ?? preset.sampleRate
|
|
36
|
+
const redactKeys = config.redactKeys ?? []
|
|
37
|
+
|
|
38
|
+
const sinks = new Map<string, TelemetrySink>()
|
|
39
|
+
/** Insertion-ordered sink names. */
|
|
40
|
+
const sinkOrder: string[] = []
|
|
41
|
+
/** Batch buffer (production preset only). */
|
|
42
|
+
const buffer: Array<{ kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord }> = []
|
|
43
|
+
|
|
44
|
+
function addSinkInternal(sink: TelemetrySink): void {
|
|
45
|
+
if (sinks.has(sink.name)) {
|
|
46
|
+
sinks.set(sink.name, sink)
|
|
47
|
+
} else {
|
|
48
|
+
sinks.set(sink.name, sink)
|
|
49
|
+
sinkOrder.push(sink.name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Console transport is always present as the zero-dependency baseline.
|
|
54
|
+
addSinkInternal(createConsoleSink({ pretty: preset.pretty }))
|
|
55
|
+
|
|
56
|
+
// When an endpoint is configured, attempt to attach the Faro engine. The
|
|
57
|
+
// import is lazy + dynamic so the optional peers never become required and
|
|
58
|
+
// Faro names stay out of the public API.
|
|
59
|
+
if (config.endpoint) {
|
|
60
|
+
const endpoint = config.endpoint
|
|
61
|
+
void import('./faro-engine.js')
|
|
62
|
+
.then(({ createFaroSink }) => createFaroSink({ app: config.app, endpoint }))
|
|
63
|
+
.then((faro) => {
|
|
64
|
+
if (faro) addSinkInternal(faro)
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
/* peers absent or init failed — console remains */
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shouldSample(): boolean {
|
|
72
|
+
if (sampleRate >= 1) return true
|
|
73
|
+
if (sampleRate <= 0) return false
|
|
74
|
+
return Math.random() < sampleRate
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dispatch(
|
|
78
|
+
entry: { kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord },
|
|
79
|
+
): void {
|
|
80
|
+
if (preset.batch) {
|
|
81
|
+
buffer.push(entry)
|
|
82
|
+
if (buffer.length >= preset.batchSize) {
|
|
83
|
+
void flushBuffer()
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
deliver(entry)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deliver(
|
|
91
|
+
entry: { kind: 'log'; record: LogRecord } | { kind: 'span'; record: SpanRecord },
|
|
92
|
+
): void {
|
|
93
|
+
for (const name of sinkOrder) {
|
|
94
|
+
const sink = sinks.get(name)
|
|
95
|
+
if (!sink) continue
|
|
96
|
+
if (entry.kind === 'log') sink.log(entry.record)
|
|
97
|
+
else sink.span(entry.record)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function flushBuffer(): Promise<void> {
|
|
102
|
+
if (buffer.length > 0) {
|
|
103
|
+
const pending = buffer.splice(0, buffer.length)
|
|
104
|
+
for (const entry of pending) deliver(entry)
|
|
105
|
+
}
|
|
106
|
+
await Promise.all(
|
|
107
|
+
sinkOrder.map((name) => sinks.get(name)?.flush() ?? Promise.resolve()),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- page-exit beacon flush (production preset) -------------------------
|
|
112
|
+
// Browser only — `navigator.sendBeacon` is used by the underlying engine
|
|
113
|
+
// transport; here we just trigger a flush on the page-exit signals.
|
|
114
|
+
const root = globalThis as {
|
|
115
|
+
addEventListener?: (type: string, listener: () => void) => void
|
|
116
|
+
document?: { visibilityState?: string }
|
|
117
|
+
navigator?: { sendBeacon?: unknown }
|
|
118
|
+
}
|
|
119
|
+
if (preset.beaconFlush && typeof root.addEventListener === 'function') {
|
|
120
|
+
const onExit = (): void => {
|
|
121
|
+
void flushBuffer()
|
|
122
|
+
}
|
|
123
|
+
root.addEventListener('pagehide', onExit)
|
|
124
|
+
root.addEventListener('visibilitychange', () => {
|
|
125
|
+
if (root.document?.visibilityState === 'hidden') onExit()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeLogger(boundContext: LogContext): Telemetry {
|
|
130
|
+
function emit(level: LogLevel, message: string, context?: LogContext): void {
|
|
131
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[preset.minLevel]) return
|
|
132
|
+
if (!shouldSample()) return
|
|
133
|
+
const merged = redact({ ...boundContext, ...context }, redactKeys)
|
|
134
|
+
const record: LogRecord = {
|
|
135
|
+
level,
|
|
136
|
+
message,
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
app: config.app,
|
|
139
|
+
env: config.env,
|
|
140
|
+
context: merged,
|
|
141
|
+
}
|
|
142
|
+
dispatch({ kind: 'log', record })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const logger: Telemetry = {
|
|
146
|
+
debug(message, context?) {
|
|
147
|
+
emit('debug', message, context)
|
|
148
|
+
},
|
|
149
|
+
info(message, context?) {
|
|
150
|
+
emit('info', message, context)
|
|
151
|
+
},
|
|
152
|
+
warn(message, context?) {
|
|
153
|
+
emit('warn', message, context)
|
|
154
|
+
},
|
|
155
|
+
error(message, context?) {
|
|
156
|
+
emit('error', message, context)
|
|
157
|
+
},
|
|
158
|
+
fatal(message, context?) {
|
|
159
|
+
emit('fatal', message, context)
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
child(context: LogContext): Telemetry {
|
|
163
|
+
return makeLogger({ ...boundContext, ...context })
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
startSpan(name: string, attributes?: LogContext): Span {
|
|
167
|
+
const startTime = Date.now()
|
|
168
|
+
let ended = false
|
|
169
|
+
return {
|
|
170
|
+
end(opts?: { error?: unknown; attributes?: LogContext }): void {
|
|
171
|
+
if (ended) return
|
|
172
|
+
ended = true
|
|
173
|
+
const endTime = Date.now()
|
|
174
|
+
const merged = redact(
|
|
175
|
+
{ ...boundContext, ...attributes, ...opts?.attributes },
|
|
176
|
+
redactKeys,
|
|
177
|
+
)
|
|
178
|
+
const err = opts?.error
|
|
179
|
+
const record: SpanRecord = {
|
|
180
|
+
name,
|
|
181
|
+
startTime,
|
|
182
|
+
endTime,
|
|
183
|
+
durationMs: endTime - startTime,
|
|
184
|
+
app: config.app,
|
|
185
|
+
env: config.env,
|
|
186
|
+
context: merged,
|
|
187
|
+
status: err ? 'error' : 'ok',
|
|
188
|
+
...(err
|
|
189
|
+
? {
|
|
190
|
+
error: {
|
|
191
|
+
name: err instanceof Error ? err.name : 'Error',
|
|
192
|
+
message: err instanceof Error ? err.message : String(err),
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
}
|
|
197
|
+
dispatch({ kind: 'span', record })
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
flush(): Promise<void> {
|
|
203
|
+
return flushBuffer()
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
get sinks(): string[] {
|
|
207
|
+
return [...sinkOrder]
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
addSink(sink: TelemetrySink): void {
|
|
211
|
+
addSinkInternal(sink)
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
removeSink(name: string): void {
|
|
215
|
+
if (sinks.has(name)) {
|
|
216
|
+
sinks.delete(name)
|
|
217
|
+
const idx = sinkOrder.indexOf(name)
|
|
218
|
+
if (idx !== -1) sinkOrder.splice(idx, 1)
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return logger
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return makeLogger({})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type { TelemetryPreset }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Severity levels, ordered low -> high. */
|
|
2
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'
|
|
3
|
+
|
|
4
|
+
/** Numeric ordering used for level threshold comparisons. */
|
|
5
|
+
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
6
|
+
debug: 10,
|
|
7
|
+
info: 20,
|
|
8
|
+
warn: 30,
|
|
9
|
+
error: 40,
|
|
10
|
+
fatal: 50,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Bound key/value pairs attached to every record emitted by a logger. */
|
|
14
|
+
export type LogContext = Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
/** A single structured log record handed to a sink. */
|
|
17
|
+
export interface LogRecord {
|
|
18
|
+
level: LogLevel
|
|
19
|
+
message: string
|
|
20
|
+
timestamp: number
|
|
21
|
+
app: string
|
|
22
|
+
env: TelemetryEnv
|
|
23
|
+
/** Merged bound context (child loggers) + per-call context, post-redaction. */
|
|
24
|
+
context: LogContext
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A span record handed to a sink when a span ends. */
|
|
28
|
+
export interface SpanRecord {
|
|
29
|
+
name: string
|
|
30
|
+
startTime: number
|
|
31
|
+
endTime: number
|
|
32
|
+
durationMs: number
|
|
33
|
+
app: string
|
|
34
|
+
env: TelemetryEnv
|
|
35
|
+
/** Merged bound context + span attributes, post-redaction. */
|
|
36
|
+
context: LogContext
|
|
37
|
+
/** 'ok' unless ended with an error. */
|
|
38
|
+
status: 'ok' | 'error'
|
|
39
|
+
error?: { name: string; message: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Vendor-neutral telemetry sink. Engines (console, Faro, custom) implement
|
|
44
|
+
* this — no vendor type ever leaks across this boundary.
|
|
45
|
+
*/
|
|
46
|
+
export interface TelemetrySink {
|
|
47
|
+
/** Engine name, for diagnostics. */
|
|
48
|
+
name: string
|
|
49
|
+
/** Receive a log record. May buffer; honor flush(). */
|
|
50
|
+
log(record: LogRecord): void
|
|
51
|
+
/** Receive a finished span. May buffer; honor flush(). */
|
|
52
|
+
span(record: SpanRecord): void
|
|
53
|
+
/** Force-deliver any buffered records. Resolves once delivery is attempted. */
|
|
54
|
+
flush(): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Telemetry environment — selects a behavior preset. */
|
|
58
|
+
export type TelemetryEnv = 'development' | 'production'
|
|
59
|
+
|
|
60
|
+
/** Config for {@link createTelemetry} — mirrors `createAI`'s config shape. */
|
|
61
|
+
export interface TelemetryConfig {
|
|
62
|
+
/** Logical app/service name attached to every record. */
|
|
63
|
+
app: string
|
|
64
|
+
/** Environment preset selector. */
|
|
65
|
+
env: TelemetryEnv
|
|
66
|
+
/**
|
|
67
|
+
* Remote collector endpoint. When omitted, telemetry stays console-only
|
|
68
|
+
* (no network engine is constructed).
|
|
69
|
+
*/
|
|
70
|
+
endpoint?: string
|
|
71
|
+
/**
|
|
72
|
+
* Master kill switch. When `false`, a tree-shakeable noop logger is
|
|
73
|
+
* returned and zero records are ever produced. Defaults to `true`.
|
|
74
|
+
*/
|
|
75
|
+
enabled?: boolean
|
|
76
|
+
/**
|
|
77
|
+
* Fraction of records kept, 0..1. Defaults to the preset value
|
|
78
|
+
* (1 in development, 0.25 in production). Records below the sample
|
|
79
|
+
* are dropped before reaching any sink.
|
|
80
|
+
*/
|
|
81
|
+
sampleRate?: number
|
|
82
|
+
/**
|
|
83
|
+
* Context keys to strip (deep) before a record is emitted. Use for
|
|
84
|
+
* PII / secrets, e.g. `['password', 'token', 'authorization']`.
|
|
85
|
+
*/
|
|
86
|
+
redactKeys?: string[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A logger bound to a context. Child loggers inherit + extend that context. */
|
|
90
|
+
export interface Logger {
|
|
91
|
+
debug(message: string, context?: LogContext): void
|
|
92
|
+
info(message: string, context?: LogContext): void
|
|
93
|
+
warn(message: string, context?: LogContext): void
|
|
94
|
+
error(message: string, context?: LogContext): void
|
|
95
|
+
fatal(message: string, context?: LogContext): void
|
|
96
|
+
/**
|
|
97
|
+
* Derive a logger with additional bound context (e.g.
|
|
98
|
+
* `{ sessionId, interviewId, turnId }`). Merges over the parent's context.
|
|
99
|
+
*/
|
|
100
|
+
child(context: LogContext): Logger
|
|
101
|
+
/** Begin a span. Call {@link Span.end} to record its duration. */
|
|
102
|
+
startSpan(name: string, attributes?: LogContext): Span
|
|
103
|
+
/** Force-deliver buffered records on every sink. */
|
|
104
|
+
flush(): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** An in-flight span returned by {@link Logger.startSpan}. */
|
|
108
|
+
export interface Span {
|
|
109
|
+
/** End the span and emit a {@link SpanRecord}. Optionally attach error/attrs. */
|
|
110
|
+
end(opts?: { error?: unknown; attributes?: LogContext }): void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Return type of {@link createTelemetry}. */
|
|
114
|
+
export interface Telemetry extends Logger {
|
|
115
|
+
/** Registered sink names, in insertion order. */
|
|
116
|
+
readonly sinks: string[]
|
|
117
|
+
/** Register an additional sink (e.g. a custom collector). */
|
|
118
|
+
addSink(sink: TelemetrySink): void
|
|
119
|
+
/** Remove a sink by name. */
|
|
120
|
+
removeSink(name: string): void
|
|
121
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-feedback — zero-dependency `devWarn` / `devError` primitives.
|
|
3
|
+
*
|
|
4
|
+
* Design constraints (epic #247, issue #248):
|
|
5
|
+
* - Guarded by `process.env.NODE_ENV !== 'production'` so production bundlers
|
|
6
|
+
* dead-code-strip every call (the guard is a static string compare that
|
|
7
|
+
* minifiers fold to `false` in prod builds).
|
|
8
|
+
* - Warn-once dedupe per `code` — a footgun is reported once, not on every
|
|
9
|
+
* render.
|
|
10
|
+
* - NO import of `@refraction-ui/logger` (no hard dependency on the telemetry
|
|
11
|
+
* lib). Forwarding to a telemetry sink happens ONLY if the consumer
|
|
12
|
+
* explicitly injects one (dependency inversion, never an import).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal structural shape of the record a telemetry sink consumes. This is a
|
|
17
|
+
* deliberate structural mirror of `@refraction-ui/logger`'s `LogRecord` — it is
|
|
18
|
+
* NOT imported, so `@refraction-ui/shared` keeps zero dependency on the
|
|
19
|
+
* telemetry lib. A consumer that wires the real logger sink satisfies this
|
|
20
|
+
* shape structurally.
|
|
21
|
+
*/
|
|
22
|
+
export interface DevFeedbackRecord {
|
|
23
|
+
level: 'warn' | 'error'
|
|
24
|
+
message: string
|
|
25
|
+
timestamp: number
|
|
26
|
+
/** Structured detail — the library-origin envelope lives here. */
|
|
27
|
+
context: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The narrow contract a consumer-injected telemetry sink must satisfy. Kept
|
|
32
|
+
* intentionally minimal and structural so the real `TelemetrySink` from
|
|
33
|
+
* `@refraction-ui/logger` is assignable WITHOUT shared importing the logger.
|
|
34
|
+
*/
|
|
35
|
+
export interface DevFeedbackSink {
|
|
36
|
+
/** Receive a single dev-feedback record. Must never throw to the caller. */
|
|
37
|
+
log(record: DevFeedbackRecord): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimal ambient view of `process.env` so we can read `NODE_ENV` WITHOUT
|
|
42
|
+
* pulling `@types/node` into this zero-dependency package. Accessed defensively
|
|
43
|
+
* (the `typeof process === 'undefined'` guard) so this is safe in browsers too.
|
|
44
|
+
*/
|
|
45
|
+
declare const process:
|
|
46
|
+
| { env?: { NODE_ENV?: string } }
|
|
47
|
+
| undefined
|
|
48
|
+
|
|
49
|
+
/** Per-code dedupe set — module-scoped so it survives across calls. */
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optional, consumer-injected sink. `null` until a consumer explicitly wires
|
|
54
|
+
* one via {@link setDevFeedbackSink}. Nothing phones home implicitly.
|
|
55
|
+
*/
|
|
56
|
+
let injectedSink: DevFeedbackSink | null = null
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wire an optional telemetry sink that {@link devWarn} / {@link devError}
|
|
60
|
+
* forward to (in addition to the console). Inversion of control: the consumer
|
|
61
|
+
* owns the sink; this package never imports it. Pass `null` to unwire.
|
|
62
|
+
*
|
|
63
|
+
* Forwarding still only happens in non-production (the calls themselves are
|
|
64
|
+
* stripped in prod), and still respects warn-once dedupe.
|
|
65
|
+
*/
|
|
66
|
+
export function setDevFeedbackSink(sink: DevFeedbackSink | null): void {
|
|
67
|
+
injectedSink = sink
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test-only / consumer-only escape hatch to reset warn-once dedupe state. */
|
|
71
|
+
export function resetDevFeedback(): void {
|
|
72
|
+
seen.clear()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isDev(): boolean {
|
|
76
|
+
// String compare (not a negated truthiness) so bundlers can statically fold
|
|
77
|
+
// `process.env.NODE_ENV` and strip the whole branch in production builds.
|
|
78
|
+
return (
|
|
79
|
+
typeof process === 'undefined' ||
|
|
80
|
+
process.env?.NODE_ENV !== 'production'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emit(
|
|
85
|
+
level: 'warn' | 'error',
|
|
86
|
+
code: string,
|
|
87
|
+
message: string,
|
|
88
|
+
detail?: Record<string, unknown>,
|
|
89
|
+
): void {
|
|
90
|
+
if (!isDev()) return
|
|
91
|
+
|
|
92
|
+
// Warn-once dedupe, keyed by (level, code) so an error and a warning sharing
|
|
93
|
+
// a code are not collapsed into one.
|
|
94
|
+
const key = `${level}:${code}`
|
|
95
|
+
if (seen.has(key)) return
|
|
96
|
+
seen.add(key)
|
|
97
|
+
|
|
98
|
+
const text = `[refraction-ui] ${code}: ${message}`
|
|
99
|
+
|
|
100
|
+
if (level === 'error') {
|
|
101
|
+
console.error(text, detail ?? '')
|
|
102
|
+
} else {
|
|
103
|
+
console.warn(text, detail ?? '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Forward to the consumer-injected sink ONLY if one was explicitly wired.
|
|
107
|
+
if (injectedSink) {
|
|
108
|
+
const record: DevFeedbackRecord = {
|
|
109
|
+
level,
|
|
110
|
+
message: `${code}: ${message}`,
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
context: { code, ...(detail ?? {}) },
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
injectedSink.log(record)
|
|
116
|
+
} catch {
|
|
117
|
+
// A broken sink must never break the consumer app.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Emit a development-only warning for a refraction-ui footgun.
|
|
124
|
+
*
|
|
125
|
+
* @param code Stable, greppable identifier (e.g. `'react/no-controlled-prop'`).
|
|
126
|
+
* @param message Human-readable explanation.
|
|
127
|
+
* @param detail Optional structured detail (forwarded to an injected sink).
|
|
128
|
+
*
|
|
129
|
+
* Stripped entirely in production. Warned at most once per `code`.
|
|
130
|
+
*/
|
|
131
|
+
export function devWarn(
|
|
132
|
+
code: string,
|
|
133
|
+
message: string,
|
|
134
|
+
detail?: Record<string, unknown>,
|
|
135
|
+
): void {
|
|
136
|
+
emit('warn', code, message, detail)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Emit a development-only error for a refraction-ui misuse / invariant break.
|
|
141
|
+
*
|
|
142
|
+
* @param code Stable, greppable identifier.
|
|
143
|
+
* @param message Human-readable explanation.
|
|
144
|
+
* @param detail Optional structured detail (forwarded to an injected sink).
|
|
145
|
+
*
|
|
146
|
+
* Stripped entirely in production. Reported at most once per `code`.
|
|
147
|
+
*/
|
|
148
|
+
export function devError(
|
|
149
|
+
code: string,
|
|
150
|
+
message: string,
|
|
151
|
+
detail?: Record<string, unknown>,
|
|
152
|
+
): void {
|
|
153
|
+
emit('error', code, message, detail)
|
|
154
|
+
}
|
package/dist/shared/index.ts
CHANGED
|
@@ -48,6 +48,18 @@ export type {
|
|
|
48
48
|
SkipLinkProps,
|
|
49
49
|
} from './skip-link.js'
|
|
50
50
|
|
|
51
|
+
export type {
|
|
52
|
+
DevFeedbackRecord,
|
|
53
|
+
DevFeedbackSink,
|
|
54
|
+
} from './dev-feedback.js'
|
|
55
|
+
|
|
56
|
+
export type {
|
|
57
|
+
LibraryFramework,
|
|
58
|
+
LibraryOriginErrorInput,
|
|
59
|
+
LibraryOriginEnvelope,
|
|
60
|
+
LibraryOriginIdentity,
|
|
61
|
+
} from './library-origin-error.js'
|
|
62
|
+
|
|
51
63
|
// Functions
|
|
52
64
|
export { mergeAriaProps, generateId, resetIdCounter } from './aria.js'
|
|
53
65
|
export { Keys, createKeyboardHandler } from './keyboard.js'
|
|
@@ -58,3 +70,16 @@ export { FOCUSABLE_SELECTOR, getFocusableElements, createFocusTrap } from './foc
|
|
|
58
70
|
export { createLiveRegion } from './live-region.js'
|
|
59
71
|
export { prefersReducedMotion, getAnimationDuration } from './motion.js'
|
|
60
72
|
export { createSkipLink } from './skip-link.js'
|
|
73
|
+
export {
|
|
74
|
+
devWarn,
|
|
75
|
+
devError,
|
|
76
|
+
setDevFeedbackSink,
|
|
77
|
+
resetDevFeedback,
|
|
78
|
+
} from './dev-feedback.js'
|
|
79
|
+
export {
|
|
80
|
+
libraryOriginError,
|
|
81
|
+
libraryOriginEnvelope,
|
|
82
|
+
stackFingerprint,
|
|
83
|
+
isLibraryOriginError,
|
|
84
|
+
captureLibraryOriginError,
|
|
85
|
+
} from './library-origin-error.js'
|