@tellann/frontend-sdk 0.1.0 → 0.3.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/auto-track.js +30 -7
- package/dist/index.d.ts +45 -0
- package/dist/index.js +207 -5
- package/package.json +4 -3
package/dist/auto-track.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
function shouldIgnore(element) {
|
|
3
3
|
let curr = element;
|
|
4
4
|
while (curr) {
|
|
5
|
-
if (curr.hasAttribute && curr.hasAttribute('data-tellann-ignore')) {
|
|
5
|
+
if (curr.hasAttribute && (curr.hasAttribute('data-tellann-ignore') || curr.hasAttribute('data-tellann-sensitive'))) {
|
|
6
6
|
return true;
|
|
7
7
|
}
|
|
8
8
|
// Ignore password inputs completely
|
|
@@ -39,28 +39,51 @@ export function sanitizeMetadata(metadata) {
|
|
|
39
39
|
'secret',
|
|
40
40
|
'private_key',
|
|
41
41
|
'access_token',
|
|
42
|
-
'authorization'
|
|
42
|
+
'authorization',
|
|
43
|
+
'cookie',
|
|
44
|
+
'session_id',
|
|
45
|
+
'sessionid',
|
|
46
|
+
'card_number',
|
|
47
|
+
'file_content'
|
|
43
48
|
];
|
|
44
|
-
const
|
|
49
|
+
const identifierKeys = ['email', 'phone', 'mobile', 'user_id', 'userid', 'account_id', 'accountid'];
|
|
50
|
+
const sanitizeUrl = (value) => {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(value, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
|
53
|
+
const names = [...new Set([...parsed.searchParams.keys()])];
|
|
54
|
+
parsed.search = names.length ? `?${names.map((name) => `${encodeURIComponent(name)}=`).join('&')}` : '';
|
|
55
|
+
parsed.hash = '';
|
|
56
|
+
return parsed.toString();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return value.slice(0, 2_000);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const sanitize = (val, keyPath = '') => {
|
|
45
63
|
if (val === null || val === undefined)
|
|
46
64
|
return val;
|
|
47
65
|
if (Array.isArray(val)) {
|
|
48
|
-
return val.map(sanitize);
|
|
66
|
+
return val.map((item, index) => sanitize(item, `${keyPath}.${index}`));
|
|
49
67
|
}
|
|
50
68
|
if (typeof val === 'object') {
|
|
51
69
|
const result = {};
|
|
52
70
|
for (const key of Object.keys(val)) {
|
|
53
71
|
const lowerKey = key.toLowerCase();
|
|
54
72
|
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
|
|
55
|
-
result[key] = '[
|
|
73
|
+
result[key] = '[NOT CAPTURED]';
|
|
74
|
+
}
|
|
75
|
+
else if (identifierKeys.some(identifier => lowerKey === identifier || lowerKey.endsWith(`_${identifier}`))) {
|
|
76
|
+
result[key] = '[PSEUDONYMIZED BY QA INGESTION]';
|
|
56
77
|
}
|
|
57
78
|
else {
|
|
58
|
-
result[key] = sanitize(val[key]);
|
|
79
|
+
result[key] = sanitize(val[key], keyPath ? `${keyPath}.${key}` : key);
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
return result;
|
|
62
83
|
}
|
|
63
|
-
|
|
84
|
+
if (typeof val === 'string' && /(^|\.)(url|href|from|to|referrer)$/i.test(keyPath))
|
|
85
|
+
return sanitizeUrl(val);
|
|
86
|
+
return typeof val === 'string' ? val.slice(0, 2_000) : val;
|
|
64
87
|
};
|
|
65
88
|
return sanitize(metadata);
|
|
66
89
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,51 @@ declare class TellannFrontendSDK {
|
|
|
38
38
|
}): void;
|
|
39
39
|
trackState(stateName: string, category?: string): void;
|
|
40
40
|
trackTransition(fromState: string, toState: string, action?: string): void;
|
|
41
|
+
trackFlowInitialState(flowVersionId: string, stateKey: string): void;
|
|
42
|
+
trackFlowStateReached(flowVersionId: string, stateKey: string): void;
|
|
43
|
+
trackFlowTransition(flowVersionId: string, fromStateKey: string, toStateKey: string, action: string): void;
|
|
44
|
+
trackFlowTerminalState(flowVersionId: string, stateKey: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Explicit adapter for Zustand, MobX, custom Context, and other stores.
|
|
47
|
+
*
|
|
48
|
+
* Shape metadata (type, length, populated) always travels. Actual values are
|
|
49
|
+
* only attached while a QA run credential is present on the page and the run
|
|
50
|
+
* is not observation-only, and even then they are carried as *candidate*
|
|
51
|
+
* protected values: the browser observer and the ingestion API both classify
|
|
52
|
+
* and encrypt them before anything is persisted. Nothing raw is ever written
|
|
53
|
+
* to the generic telemetry wire.
|
|
54
|
+
*/
|
|
55
|
+
trackClientState(store: string, key: string, previous: unknown, next: unknown): void;
|
|
56
|
+
/**
|
|
57
|
+
* Redux middleware. Records the action type, which top-level slice paths
|
|
58
|
+
* actually changed, and protected before/after values for those slices.
|
|
59
|
+
*
|
|
60
|
+
* const store = configureStore({
|
|
61
|
+
* reducer,
|
|
62
|
+
* middleware: (get) => get().concat(TELLANN.createReduxMiddleware()),
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
createReduxMiddleware(): (store: {
|
|
66
|
+
getState(): unknown;
|
|
67
|
+
}) => (next: (action: unknown) => unknown) => (action: unknown) => unknown;
|
|
68
|
+
/**
|
|
69
|
+
* React Context adapter. Only providers explicitly identified and approved in
|
|
70
|
+
* the validated Flow instrumentation manifest should call this — there is no
|
|
71
|
+
* blanket interception of React internals, because that cannot be done
|
|
72
|
+
* reliably and would misreport what was actually captured.
|
|
73
|
+
*
|
|
74
|
+
* useEffect(() => TELLANN.trackContextValue('AuthContext', 'user', value), [value]);
|
|
75
|
+
*/
|
|
76
|
+
trackContextValue(providerName: string, key: string, value: unknown): void;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps an approved `useState` setter so Flow-relevant state changes are
|
|
79
|
+
* recorded. Intended to be applied to the specific setters identified during
|
|
80
|
+
* static analysis, not to every setter in the application.
|
|
81
|
+
*
|
|
82
|
+
* const [email, setEmail] = useState('');
|
|
83
|
+
* const setTracked = TELLANN.trackStateSetter('CheckoutForm', 'email', setEmail, email);
|
|
84
|
+
*/
|
|
85
|
+
trackStateSetter<T>(componentName: string, key: string, setter: (value: T) => void, current?: T): (value: T) => void;
|
|
41
86
|
startWorkflow(workflowName: string): string;
|
|
42
87
|
completeWorkflow(workflowId: string): void;
|
|
43
88
|
failWorkflow(workflowId: string, reason?: string): void;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
import { v4 as uuidv4 } from 'uuid';
|
|
2
2
|
import { WorkflowTracker } from './workflow-tracker.js';
|
|
3
3
|
import { setupAutoTrack, sanitizeMetadata } from './auto-track.js';
|
|
4
|
+
const CLIENT_STATE_SECRET_KEY = /password|passwd|passcode|secret|token|authorization|cookie|session|auth|private.?key|cvv|cvc|card/i;
|
|
5
|
+
/** Shape-only description; always safe to send regardless of environment. */
|
|
6
|
+
function describeClientStateValue(value) {
|
|
7
|
+
return {
|
|
8
|
+
type: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value,
|
|
9
|
+
length: typeof value === 'string' || Array.isArray(value) ? value.length : null,
|
|
10
|
+
populated: value !== null && value !== undefined && value !== '',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* True only while a desktop QA run credential is present on the page. Outside a
|
|
15
|
+
* run — and in any observation-only run — client state values never leave the
|
|
16
|
+
* page at all.
|
|
17
|
+
*/
|
|
18
|
+
function qaRunActive() {
|
|
19
|
+
const run = globalThis.__TELLANN_RUN__;
|
|
20
|
+
return Boolean(run && run.runId && run.relayToken);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The local relay the desktop observer injected for this run, if any.
|
|
24
|
+
*
|
|
25
|
+
* While a guided run is active every event has to travel through the relay:
|
|
26
|
+
* only the relay attaches the run-ingestion credential, and the collector
|
|
27
|
+
* advances the Flow boundary solely for credentialed events. A
|
|
28
|
+
* `FLOW_INITIAL_STATE` flushed straight to the configured gateway is ingested
|
|
29
|
+
* as ordinary telemetry, so the run would sit at "Waiting for
|
|
30
|
+
* FLOW_INITIAL_STATE" no matter how many times the page emits it.
|
|
31
|
+
*/
|
|
32
|
+
function activeRunRelay() {
|
|
33
|
+
const run = globalThis.__TELLANN_RUN__;
|
|
34
|
+
if (!run || typeof run.runId !== 'string' || typeof run.relayToken !== 'string')
|
|
35
|
+
return null;
|
|
36
|
+
const endpoint = typeof run.relayEndpoint === 'string' ? run.relayEndpoint.replace(/\/$/, '') : '';
|
|
37
|
+
return endpoint ? { endpoint, token: run.relayToken } : null;
|
|
38
|
+
}
|
|
39
|
+
function serializeCandidate(value) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (typeof value === 'string')
|
|
43
|
+
return value.slice(0, 16_384);
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(value)?.slice(0, 16_384);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Attaches candidate protected values for the QA pipeline. These are proposals,
|
|
53
|
+
* not decisions: the browser observer re-derives a classification and the
|
|
54
|
+
* ingestion API applies the authoritative server-side floor before persisting.
|
|
55
|
+
* Anything whose key looks like a secret is dropped here as well, so a secret
|
|
56
|
+
* never leaves the page even as a candidate.
|
|
57
|
+
*/
|
|
58
|
+
function qaCandidateValues(key, previous, next) {
|
|
59
|
+
if (!qaRunActive() || CLIENT_STATE_SECRET_KEY.test(String(key)))
|
|
60
|
+
return {};
|
|
61
|
+
const previousValue = serializeCandidate(previous);
|
|
62
|
+
const nextValue = serializeCandidate(next);
|
|
63
|
+
if (previousValue === undefined && nextValue === undefined)
|
|
64
|
+
return {};
|
|
65
|
+
return {
|
|
66
|
+
qaProtectedCandidates: [
|
|
67
|
+
...(previousValue === undefined ? [] : [{ keyPath: `clientState.${key}.previousValue`, value: previousValue }]),
|
|
68
|
+
...(nextValue === undefined ? [] : [{ keyPath: `clientState.${key}.newValue`, value: nextValue }]),
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function safeState(value) {
|
|
73
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
74
|
+
? value
|
|
75
|
+
: {};
|
|
76
|
+
}
|
|
77
|
+
/** Top-level slice keys whose reference actually changed. */
|
|
78
|
+
function changedSlicePaths(before, after) {
|
|
79
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
80
|
+
return [...keys].filter((key) => before[key] !== after[key]);
|
|
81
|
+
}
|
|
82
|
+
function pickPaths(source, paths) {
|
|
83
|
+
const result = {};
|
|
84
|
+
for (const path of paths.slice(0, 50))
|
|
85
|
+
result[path] = source[path];
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
4
88
|
const MAX_EVENT_SIZE_BYTES = 32 * 1024; // 32 KB limit for standard events
|
|
5
89
|
const MAX_REPLAY_SIZE_BYTES = 128 * 1024; // 128 KB limit for replay events (e.g. if eventType is a replay event)
|
|
6
90
|
class TellannFrontendSDK {
|
|
@@ -64,6 +148,22 @@ class TellannFrontendSDK {
|
|
|
64
148
|
}
|
|
65
149
|
return;
|
|
66
150
|
}
|
|
151
|
+
// Enforce the contract against the caller's payload before redaction. A
|
|
152
|
+
// multi-kilobyte value must not become an apparently valid tiny event just
|
|
153
|
+
// because the privacy layer replaced it with a marker.
|
|
154
|
+
try {
|
|
155
|
+
const rawPayload = JSON.stringify({ eventType, metadata });
|
|
156
|
+
const rawSize = typeof Blob !== 'undefined' ? new Blob([rawPayload]).size : rawPayload.length;
|
|
157
|
+
const rawLimit = eventType.includes('REPLAY') ? MAX_REPLAY_SIZE_BYTES : MAX_EVENT_SIZE_BYTES;
|
|
158
|
+
if (rawSize > rawLimit) {
|
|
159
|
+
console.error(`[Tellann] Event of type "${eventType}" discarded. Size (${rawSize} bytes) exceeds limit of ${rawLimit} bytes.`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
console.error('[Tellann] Failed to compute size of event, discarding', err);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
67
167
|
// Apply privacy-by-default metadata sanitization
|
|
68
168
|
const sanitizedMetadata = sanitizeMetadata(metadata);
|
|
69
169
|
const event = {
|
|
@@ -134,6 +234,100 @@ class TellannFrontendSDK {
|
|
|
134
234
|
action: action || 'NAVIGATE',
|
|
135
235
|
});
|
|
136
236
|
}
|
|
237
|
+
trackFlowInitialState(flowVersionId, stateKey) {
|
|
238
|
+
this.trackEvent('FLOW_INITIAL_STATE', { flowVersionId, stateKey });
|
|
239
|
+
}
|
|
240
|
+
trackFlowStateReached(flowVersionId, stateKey) {
|
|
241
|
+
this.trackEvent('FLOW_STATE_REACHED', { flowVersionId, stateKey });
|
|
242
|
+
}
|
|
243
|
+
trackFlowTransition(flowVersionId, fromStateKey, toStateKey, action) {
|
|
244
|
+
this.trackEvent('FLOW_TRANSITION', { flowVersionId, stateKey: toStateKey, fromStateKey, toStateKey, action });
|
|
245
|
+
}
|
|
246
|
+
trackFlowTerminalState(flowVersionId, stateKey) {
|
|
247
|
+
this.trackEvent('FLOW_TERMINAL_STATE', { flowVersionId, stateKey });
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Explicit adapter for Zustand, MobX, custom Context, and other stores.
|
|
251
|
+
*
|
|
252
|
+
* Shape metadata (type, length, populated) always travels. Actual values are
|
|
253
|
+
* only attached while a QA run credential is present on the page and the run
|
|
254
|
+
* is not observation-only, and even then they are carried as *candidate*
|
|
255
|
+
* protected values: the browser observer and the ingestion API both classify
|
|
256
|
+
* and encrypt them before anything is persisted. Nothing raw is ever written
|
|
257
|
+
* to the generic telemetry wire.
|
|
258
|
+
*/
|
|
259
|
+
trackClientState(store, key, previous, next) {
|
|
260
|
+
this.trackEvent('BUSINESS_EVENT', {
|
|
261
|
+
businessEventType: 'QA_CLIENT_STATE_MUTATION',
|
|
262
|
+
store: String(store).slice(0, 100),
|
|
263
|
+
key: String(key).slice(0, 200),
|
|
264
|
+
previous: describeClientStateValue(previous),
|
|
265
|
+
next: describeClientStateValue(next),
|
|
266
|
+
...qaCandidateValues(key, previous, next),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Redux middleware. Records the action type, which top-level slice paths
|
|
271
|
+
* actually changed, and protected before/after values for those slices.
|
|
272
|
+
*
|
|
273
|
+
* const store = configureStore({
|
|
274
|
+
* reducer,
|
|
275
|
+
* middleware: (get) => get().concat(TELLANN.createReduxMiddleware()),
|
|
276
|
+
* });
|
|
277
|
+
*/
|
|
278
|
+
createReduxMiddleware() {
|
|
279
|
+
const sdk = this;
|
|
280
|
+
return (store) => (next) => (action) => {
|
|
281
|
+
const before = safeState(store.getState());
|
|
282
|
+
const result = next(action);
|
|
283
|
+
const after = safeState(store.getState());
|
|
284
|
+
const type = String(action?.type ?? 'UNKNOWN_ACTION');
|
|
285
|
+
const changed = changedSlicePaths(before, after);
|
|
286
|
+
if (changed.length) {
|
|
287
|
+
sdk.trackEvent('BUSINESS_EVENT', {
|
|
288
|
+
businessEventType: 'QA_CLIENT_STATE_MUTATION',
|
|
289
|
+
store: 'redux',
|
|
290
|
+
key: type.slice(0, 200),
|
|
291
|
+
actionType: type.slice(0, 200),
|
|
292
|
+
changedSlicePaths: changed.slice(0, 50),
|
|
293
|
+
previous: describeClientStateValue(before),
|
|
294
|
+
next: describeClientStateValue(after),
|
|
295
|
+
...qaCandidateValues(type, pickPaths(before, changed), pickPaths(after, changed)),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* React Context adapter. Only providers explicitly identified and approved in
|
|
303
|
+
* the validated Flow instrumentation manifest should call this — there is no
|
|
304
|
+
* blanket interception of React internals, because that cannot be done
|
|
305
|
+
* reliably and would misreport what was actually captured.
|
|
306
|
+
*
|
|
307
|
+
* useEffect(() => TELLANN.trackContextValue('AuthContext', 'user', value), [value]);
|
|
308
|
+
*/
|
|
309
|
+
trackContextValue(providerName, key, value) {
|
|
310
|
+
this.trackClientState(`context:${providerName}`, key, undefined, value);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Wraps an approved `useState` setter so Flow-relevant state changes are
|
|
314
|
+
* recorded. Intended to be applied to the specific setters identified during
|
|
315
|
+
* static analysis, not to every setter in the application.
|
|
316
|
+
*
|
|
317
|
+
* const [email, setEmail] = useState('');
|
|
318
|
+
* const setTracked = TELLANN.trackStateSetter('CheckoutForm', 'email', setEmail, email);
|
|
319
|
+
*/
|
|
320
|
+
trackStateSetter(componentName, key, setter, current) {
|
|
321
|
+
let previous = current;
|
|
322
|
+
return (value) => {
|
|
323
|
+
const resolved = typeof value === 'function'
|
|
324
|
+
? value(previous)
|
|
325
|
+
: value;
|
|
326
|
+
this.trackClientState(`useState:${componentName}`, key, previous, resolved);
|
|
327
|
+
previous = resolved;
|
|
328
|
+
setter(resolved);
|
|
329
|
+
};
|
|
330
|
+
}
|
|
137
331
|
startWorkflow(workflowName) {
|
|
138
332
|
const id = this.workflowTracker.start(workflowName);
|
|
139
333
|
this.trackEvent('WORKFLOW_STARTED', {
|
|
@@ -220,13 +414,21 @@ class TellannFrontendSDK {
|
|
|
220
414
|
console.error(`[Tellann] Batch payload size of ${payloadSize} bytes exceeds the 5 MB limit. Dropping batch.`);
|
|
221
415
|
return;
|
|
222
416
|
}
|
|
417
|
+
const relay = activeRunRelay();
|
|
418
|
+
const target = relay ? relay.endpoint : this.config.endpoint;
|
|
223
419
|
const headers = {
|
|
224
420
|
'Content-Type': 'application/json',
|
|
225
421
|
};
|
|
226
|
-
if (
|
|
422
|
+
if (relay) {
|
|
423
|
+
headers.Authorization = `Bearer ${relay.token}`;
|
|
424
|
+
}
|
|
425
|
+
else if (this.config.apiKey) {
|
|
227
426
|
headers.Authorization = `Bearer ${this.config.apiKey}`;
|
|
228
427
|
}
|
|
229
|
-
|
|
428
|
+
// The relay re-derives the environment from the run correlation, and its
|
|
429
|
+
// CORS allow-list does not carry this header — sending it would fail the
|
|
430
|
+
// preflight and lose the batch.
|
|
431
|
+
if (!relay && this.config.environmentId) {
|
|
230
432
|
headers['x-tellann-environment-id'] = this.config.environmentId;
|
|
231
433
|
}
|
|
232
434
|
if (this.config.runId)
|
|
@@ -236,16 +438,16 @@ class TellannFrontendSDK {
|
|
|
236
438
|
if (this.config.traceId)
|
|
237
439
|
headers['x-tellann-trace-id'] = this.config.traceId;
|
|
238
440
|
// sendBeacon cannot set auth headers, so only use it for unauthenticated direct collector targets.
|
|
239
|
-
if (!this.config.apiKey && !this.config.environmentId && navigator.sendBeacon && typeof Blob !== 'undefined') {
|
|
441
|
+
if (!relay && !this.config.apiKey && !this.config.environmentId && navigator.sendBeacon && typeof Blob !== 'undefined') {
|
|
240
442
|
const blob = new Blob([payload], { type: 'application/json' });
|
|
241
|
-
const success = navigator.sendBeacon(`${
|
|
443
|
+
const success = navigator.sendBeacon(`${target}/v1/events/batch`, blob);
|
|
242
444
|
if (!success) {
|
|
243
445
|
throw new Error('sendBeacon returned false');
|
|
244
446
|
}
|
|
245
447
|
}
|
|
246
448
|
else {
|
|
247
449
|
// Fallback to fetch
|
|
248
|
-
await fetch(`${
|
|
450
|
+
await fetch(`${target}/v1/events/batch`, {
|
|
249
451
|
method: 'POST',
|
|
250
452
|
headers,
|
|
251
453
|
body: payload,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tellann/frontend-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tellann browser telemetry and QA-run correlation SDK",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"types": "./dist/index.d.ts",
|
|
25
25
|
"import": "./dist/index.js",
|
|
26
26
|
"default": "./dist/index.js"
|
|
27
|
-
}
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"dist",
|
|
@@ -45,6 +46,6 @@
|
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "tsc",
|
|
47
48
|
"dev": "tsc -w",
|
|
48
|
-
"test": "node --test dist/index.test.js"
|
|
49
|
+
"test": "tsc && node --test dist/index.test.js"
|
|
49
50
|
}
|
|
50
51
|
}
|