siteshield 0.0.0-stage → 0.1.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/README.md +35 -2
- package/dist/core/counters.d.ts +35 -0
- package/dist/core/counters.js +91 -0
- package/dist/core/engine.d.ts +9 -0
- package/dist/core/engine.js +78 -0
- package/dist/core/types.d.ts +39 -0
- package/dist/core/types.js +1 -0
- package/dist/integrations/next.d.ts +15 -0
- package/dist/integrations/next.js +74 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SiteShield Next.js adapter
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
SiteShield's Node.js security engine and Next.js request adapter. This is an early MVP for team demonstrations, not a complete edge firewall. It makes local decisions and can report restricted events asynchronously to a separately deployed SiteShield API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install siteshield
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Create `proxy.ts` in a Next.js 16 app (Node.js runtime):
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { NextResponse, type NextRequest, type NextFetchEvent } from 'next/server';
|
|
15
|
+
import { createNextShield, createApiEventReporter } from 'siteshield/next';
|
|
16
|
+
|
|
17
|
+
const shield = createNextShield({
|
|
18
|
+
siteId: process.env.SITESHIELD_SITE_ID!,
|
|
19
|
+
policy: { mode: 'monitor' },
|
|
20
|
+
getClientIp: (request) => request.headers.get('x-company-client-ip') ?? undefined,
|
|
21
|
+
eventReporter: createApiEventReporter(
|
|
22
|
+
process.env.SITESHIELD_API_URL!,
|
|
23
|
+
process.env.SITESHIELD_SITE_KEY!,
|
|
24
|
+
),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
28
|
+
return (await shield(request, event)) ?? NextResponse.next();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const config = { matcher: ['/api/:path*', '/login'] };
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The example IP header is **not** automatically trustworthy. Only use it if your ingress overwrites client-supplied headers and prevents direct origin access. A missing/invalid IP or counter-store failure produces HTTP 503, never a silent allow. `mode: 'monitor'` records would-be restrictions without enforcing them; test and set `mode: 'enforce'` when ready. The local in-memory counter default is only appropriate for a single instance. For multiple instances, pass a shared `RedisCounterStore` via `counterStore` (exported from `siteshield/counters`).
|
|
35
|
+
|
|
36
|
+
The API URL, site ID, and site key must be server-only environment variables (never `NEXT_PUBLIC_`). The central SiteShield API must be deployed and configured separately. Do not expose this package as a substitute for network/edge DDoS protection. The WordPress plugin is distributed separately from this npm package.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare function counterSitePrefix(siteId: string): string;
|
|
2
|
+
export declare function counterKey(siteId: string, ip: string): string;
|
|
3
|
+
export interface CounterSnapshot {
|
|
4
|
+
requests: number;
|
|
5
|
+
loginAttempts: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CounterStore {
|
|
8
|
+
record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
|
|
9
|
+
reset(prefix?: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** Deterministic store for local development and unit tests. */
|
|
12
|
+
export declare class MemoryCounterStore implements CounterStore {
|
|
13
|
+
private readonly entries;
|
|
14
|
+
record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
|
|
15
|
+
reset(prefix?: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface RedisEvalClient {
|
|
18
|
+
eval(script: string, options: {
|
|
19
|
+
keys: string[];
|
|
20
|
+
arguments: string[];
|
|
21
|
+
}): Promise<unknown>;
|
|
22
|
+
scanIterator(options: {
|
|
23
|
+
MATCH: string;
|
|
24
|
+
COUNT: number;
|
|
25
|
+
}): AsyncIterable<string[]>;
|
|
26
|
+
del(key: string): Promise<number>;
|
|
27
|
+
}
|
|
28
|
+
/** Redis-backed sliding windows. The Lua script makes trim/add/count atomic across API instances. */
|
|
29
|
+
export declare class RedisCounterStore implements CounterStore {
|
|
30
|
+
private readonly client;
|
|
31
|
+
private readonly prefix;
|
|
32
|
+
constructor(client: RedisEvalClient, prefix?: string);
|
|
33
|
+
record(key: string, now: number, requestWindowMs: number, isLogin: boolean, loginWindowMs: number): Promise<CounterSnapshot>;
|
|
34
|
+
reset(prefix?: string): Promise<void>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
export function counterSitePrefix(siteId) {
|
|
3
|
+
return `${createHash("sha256").update(siteId).digest("hex")}:`;
|
|
4
|
+
}
|
|
5
|
+
export function counterKey(siteId, ip) {
|
|
6
|
+
return `${counterSitePrefix(siteId)}${createHash("sha256").update(ip).digest("hex")}`;
|
|
7
|
+
}
|
|
8
|
+
/** Deterministic store for local development and unit tests. */
|
|
9
|
+
export class MemoryCounterStore {
|
|
10
|
+
entries = new Map();
|
|
11
|
+
async record(key, now, requestWindowMs, isLogin, loginWindowMs) {
|
|
12
|
+
const entry = this.entries.get(key) ?? { requests: [], loginAttempts: [] };
|
|
13
|
+
entry.requests = entry.requests.filter((at) => now - at < requestWindowMs);
|
|
14
|
+
entry.loginAttempts = entry.loginAttempts.filter((at) => now - at < loginWindowMs);
|
|
15
|
+
entry.requests.push(now);
|
|
16
|
+
if (isLogin)
|
|
17
|
+
entry.loginAttempts.push(now);
|
|
18
|
+
this.entries.set(key, entry);
|
|
19
|
+
return {
|
|
20
|
+
requests: entry.requests.length,
|
|
21
|
+
loginAttempts: entry.loginAttempts.length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async reset(prefix) {
|
|
25
|
+
if (!prefix)
|
|
26
|
+
this.entries.clear();
|
|
27
|
+
else
|
|
28
|
+
for (const key of this.entries.keys())
|
|
29
|
+
if (key.startsWith(prefix))
|
|
30
|
+
this.entries.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Redis-backed sliding windows. The Lua script makes trim/add/count atomic across API instances. */
|
|
34
|
+
export class RedisCounterStore {
|
|
35
|
+
client;
|
|
36
|
+
prefix;
|
|
37
|
+
constructor(client, prefix = "siteshield:counter:") {
|
|
38
|
+
this.client = client;
|
|
39
|
+
this.prefix = prefix;
|
|
40
|
+
if (!/^[a-zA-Z0-9:_-]+$/.test(prefix))
|
|
41
|
+
throw new Error("Invalid Redis counter namespace");
|
|
42
|
+
}
|
|
43
|
+
async record(key, now, requestWindowMs, isLogin, loginWindowMs) {
|
|
44
|
+
const requestKey = `${this.prefix}{${key}}:requests`;
|
|
45
|
+
const loginKey = `${this.prefix}{${key}}:logins`;
|
|
46
|
+
const script = `
|
|
47
|
+
local clock = redis.call('TIME')
|
|
48
|
+
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
|
49
|
+
local requestWindow = tonumber(ARGV[2])
|
|
50
|
+
local loginWindow = tonumber(ARGV[3])
|
|
51
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - requestWindow)
|
|
52
|
+
redis.call('ZADD', KEYS[1], now, ARGV[4])
|
|
53
|
+
redis.call('EXPIRE', KEYS[1], math.ceil(requestWindow / 1000) + 60)
|
|
54
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - loginWindow)
|
|
55
|
+
if ARGV[5] == '1' then
|
|
56
|
+
redis.call('ZADD', KEYS[2], now, ARGV[4])
|
|
57
|
+
redis.call('EXPIRE', KEYS[2], math.ceil(loginWindow / 1000) + 60)
|
|
58
|
+
end
|
|
59
|
+
return { redis.call('ZCARD', KEYS[1]), redis.call('ZCARD', KEYS[2]) }
|
|
60
|
+
`;
|
|
61
|
+
const result = await this.client.eval(script, {
|
|
62
|
+
keys: [requestKey, loginKey],
|
|
63
|
+
arguments: [
|
|
64
|
+
String(now),
|
|
65
|
+
String(requestWindowMs),
|
|
66
|
+
String(loginWindowMs),
|
|
67
|
+
`${now}:${randomUUID()}`,
|
|
68
|
+
isLogin ? "1" : "0",
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
if (!Array.isArray(result) ||
|
|
72
|
+
result.length !== 2 ||
|
|
73
|
+
result.some((value) => !Number.isSafeInteger(value) || Number(value) < 0))
|
|
74
|
+
throw new Error("Invalid Redis counter response");
|
|
75
|
+
const values = result;
|
|
76
|
+
return { requests: Number(values[0]), loginAttempts: Number(values[1]) };
|
|
77
|
+
}
|
|
78
|
+
async reset(prefix) {
|
|
79
|
+
if (prefix && !/^[a-f0-9]{64}:$/.test(prefix))
|
|
80
|
+
throw new Error("Invalid counter reset prefix");
|
|
81
|
+
const pattern = prefix ? `${this.prefix}{${prefix}*}:*` : `${this.prefix}*`;
|
|
82
|
+
for await (const keys of this.client.scanIterator({
|
|
83
|
+
MATCH: pattern,
|
|
84
|
+
COUNT: 100,
|
|
85
|
+
})) {
|
|
86
|
+
// Delete individually to remain compatible with Redis Cluster hash slots.
|
|
87
|
+
for (const key of keys)
|
|
88
|
+
await this.client.del(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Evaluation, RequestContext, SitePolicy } from "./types.js";
|
|
2
|
+
import { type CounterStore } from "./counters.js";
|
|
3
|
+
export declare class SecurityEngine {
|
|
4
|
+
private readonly policy;
|
|
5
|
+
private readonly counters;
|
|
6
|
+
constructor(policy?: Partial<SitePolicy>, counters?: CounterStore);
|
|
7
|
+
evaluate(request: RequestContext): Promise<Evaluation>;
|
|
8
|
+
reset(siteId?: string): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { counterKey, counterSitePrefix, MemoryCounterStore } from "./counters.js";
|
|
3
|
+
const defaultPolicy = {
|
|
4
|
+
mode: "enforce",
|
|
5
|
+
windowMs: 60_000,
|
|
6
|
+
maxRequests: 60,
|
|
7
|
+
loginPaths: ["/wp-login.php", "/wp-admin", "/api/auth/login", "/login"],
|
|
8
|
+
loginWindowMs: 60_000,
|
|
9
|
+
maxLoginAttempts: 8,
|
|
10
|
+
blockScore: 5,
|
|
11
|
+
limitScore: 3,
|
|
12
|
+
};
|
|
13
|
+
export class SecurityEngine {
|
|
14
|
+
policy;
|
|
15
|
+
counters;
|
|
16
|
+
constructor(policy = {}, counters = new MemoryCounterStore()) {
|
|
17
|
+
this.policy = { ...defaultPolicy, ...policy };
|
|
18
|
+
this.counters = counters;
|
|
19
|
+
}
|
|
20
|
+
async evaluate(request) {
|
|
21
|
+
const now = request.now ?? Date.now();
|
|
22
|
+
const key = counterKey(request.siteId, request.ip);
|
|
23
|
+
const isLogin = this.policy.loginPaths.some((path) => request.path === path || request.path.startsWith(`${path}/`));
|
|
24
|
+
const counter = await this.counters.record(key, now, this.policy.windowMs, isLogin, this.policy.loginWindowMs);
|
|
25
|
+
const reasons = [];
|
|
26
|
+
let score = 0;
|
|
27
|
+
const crawler = this.policy.trustedCrawler
|
|
28
|
+
? await this.policy.trustedCrawler(request)
|
|
29
|
+
: false;
|
|
30
|
+
if (crawler) {
|
|
31
|
+
reasons.push("verified crawler");
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (counter.requests > this.policy.maxRequests) {
|
|
35
|
+
score += 3;
|
|
36
|
+
reasons.push("request rate exceeded");
|
|
37
|
+
}
|
|
38
|
+
if (isLogin && counter.loginAttempts > this.policy.maxLoginAttempts) {
|
|
39
|
+
score += 4;
|
|
40
|
+
reasons.push("login attempt rate exceeded");
|
|
41
|
+
}
|
|
42
|
+
if (!request.userAgent ||
|
|
43
|
+
/bot|crawler|spider|scrapy|curl|python-requests/i.test(request.userAgent)) {
|
|
44
|
+
score += 1;
|
|
45
|
+
reasons.push("automated user agent");
|
|
46
|
+
}
|
|
47
|
+
if (request.method.toUpperCase() === "POST" && isLogin)
|
|
48
|
+
score += 1;
|
|
49
|
+
}
|
|
50
|
+
let decision = score >= this.policy.blockScore
|
|
51
|
+
? "block"
|
|
52
|
+
: score >= this.policy.limitScore
|
|
53
|
+
? "limit"
|
|
54
|
+
: "allow";
|
|
55
|
+
if (this.policy.mode === "monitor" && decision !== "allow")
|
|
56
|
+
decision = "monitor";
|
|
57
|
+
const event = {
|
|
58
|
+
id: randomUUID(),
|
|
59
|
+
siteId: request.siteId,
|
|
60
|
+
timestamp: new Date(now).toISOString(),
|
|
61
|
+
request: {
|
|
62
|
+
siteId: request.siteId,
|
|
63
|
+
ip: request.ip,
|
|
64
|
+
method: request.method,
|
|
65
|
+
path: request.path,
|
|
66
|
+
userAgent: request.userAgent,
|
|
67
|
+
},
|
|
68
|
+
decision,
|
|
69
|
+
score,
|
|
70
|
+
reasons,
|
|
71
|
+
counters: counter,
|
|
72
|
+
};
|
|
73
|
+
return { decision, score, reasons, event };
|
|
74
|
+
}
|
|
75
|
+
reset(siteId) {
|
|
76
|
+
return this.counters.reset(siteId ? counterSitePrefix(siteId) : undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Decision = "allow" | "monitor" | "limit" | "block";
|
|
2
|
+
export interface RequestContext {
|
|
3
|
+
siteId: string;
|
|
4
|
+
ip: string;
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
userAgent?: string;
|
|
8
|
+
now?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SitePolicy {
|
|
11
|
+
mode: "monitor" | "enforce";
|
|
12
|
+
windowMs: number;
|
|
13
|
+
maxRequests: number;
|
|
14
|
+
loginPaths: string[];
|
|
15
|
+
loginWindowMs: number;
|
|
16
|
+
maxLoginAttempts: number;
|
|
17
|
+
blockScore: number;
|
|
18
|
+
limitScore: number;
|
|
19
|
+
trustedCrawler?: (request: RequestContext) => boolean | Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export interface SecurityEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
siteId: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
request: Omit<RequestContext, "now">;
|
|
26
|
+
decision: Decision;
|
|
27
|
+
score: number;
|
|
28
|
+
reasons: string[];
|
|
29
|
+
counters: {
|
|
30
|
+
requests: number;
|
|
31
|
+
loginAttempts: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface Evaluation {
|
|
35
|
+
decision: Decision;
|
|
36
|
+
score: number;
|
|
37
|
+
reasons: string[];
|
|
38
|
+
event: SecurityEvent;
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CounterStore } from '../core/counters.js';
|
|
2
|
+
import type { Evaluation, SitePolicy } from '../core/types.js';
|
|
3
|
+
export interface NextShieldOptions {
|
|
4
|
+
siteId: string;
|
|
5
|
+
policy?: Partial<SitePolicy>;
|
|
6
|
+
counterStore?: CounterStore;
|
|
7
|
+
eventReporter?: (evaluation: Evaluation) => Promise<void>;
|
|
8
|
+
/** Must return an IP from a source sanitized by your hosting platform, never arbitrary client headers. */
|
|
9
|
+
getClientIp: (request: Request) => string | undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Use this from Next.js middleware/proxy. Return undefined to continue the request. */
|
|
12
|
+
export declare function createNextShield(options: NextShieldOptions): (request: Request, event?: {
|
|
13
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
14
|
+
}) => Promise<Response | undefined>;
|
|
15
|
+
export declare function createApiEventReporter(apiUrl: string, siteKey: string): (evaluation: Evaluation) => Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SecurityEngine } from '../core/engine.js';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
function requestContext(request, siteId, ip) {
|
|
4
|
+
const url = new URL(request.url);
|
|
5
|
+
return {
|
|
6
|
+
siteId,
|
|
7
|
+
ip,
|
|
8
|
+
method: request.method,
|
|
9
|
+
path: url.pathname,
|
|
10
|
+
userAgent: request.headers.get('user-agent') ?? undefined
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function enforcementResponse(evaluation, retryAfter) {
|
|
14
|
+
if (evaluation.decision === 'limit') {
|
|
15
|
+
return new Response(JSON.stringify({ error: 'rate_limited', requestId: evaluation.event.id }), {
|
|
16
|
+
status: 429,
|
|
17
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'retry-after': String(retryAfter), 'x-siteshield-decision': 'limit' }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return new Response(JSON.stringify({ error: 'request_blocked', requestId: evaluation.event.id }), {
|
|
21
|
+
status: 403,
|
|
22
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-siteshield-decision': 'block' }
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Use this from Next.js middleware/proxy. Return undefined to continue the request. */
|
|
26
|
+
export function createNextShield(options) {
|
|
27
|
+
if (!options.siteId?.trim())
|
|
28
|
+
throw new Error('siteId is required');
|
|
29
|
+
if (!options.getClientIp)
|
|
30
|
+
throw new Error('getClientIp is required');
|
|
31
|
+
const engine = new SecurityEngine(options.policy, options.counterStore);
|
|
32
|
+
const retryAfter = Math.ceil((options.policy?.windowMs ?? 60_000) / 1000);
|
|
33
|
+
return async function siteShield(request, event) {
|
|
34
|
+
let evaluation;
|
|
35
|
+
try {
|
|
36
|
+
const ip = options.getClientIp(request);
|
|
37
|
+
if (!ip || !isIP(ip))
|
|
38
|
+
throw new Error('A trusted client IP is required');
|
|
39
|
+
evaluation = await engine.evaluate(requestContext(request, options.siteId, ip));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Counter/identity failures are not disguised as successful protection.
|
|
43
|
+
return Response.json({ error: 'protection_unavailable' }, { status: 503, headers: { 'cache-control': 'no-store' } });
|
|
44
|
+
}
|
|
45
|
+
if (options.eventReporter && evaluation.decision !== 'allow') {
|
|
46
|
+
const report = Promise.resolve().then(() => options.eventReporter(evaluation)).catch(() => undefined);
|
|
47
|
+
event?.waitUntil(report);
|
|
48
|
+
}
|
|
49
|
+
if (evaluation.decision === 'allow' || evaluation.decision === 'monitor')
|
|
50
|
+
return undefined;
|
|
51
|
+
return enforcementResponse(evaluation, retryAfter);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function createApiEventReporter(apiUrl, siteKey) {
|
|
55
|
+
const url = new URL(apiUrl);
|
|
56
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
|
57
|
+
throw new Error('Invalid API URL');
|
|
58
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
59
|
+
throw new Error('Use HTTPS for non-local API URLs');
|
|
60
|
+
if (!siteKey?.trim())
|
|
61
|
+
throw new Error('siteKey is required');
|
|
62
|
+
const endpoint = new URL('/v1/events', apiUrl).toString();
|
|
63
|
+
return async (evaluation) => {
|
|
64
|
+
const response = await fetch(endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', 'x-siteshield-key': siteKey },
|
|
67
|
+
body: JSON.stringify(evaluation.event),
|
|
68
|
+
signal: AbortSignal.timeout(1500),
|
|
69
|
+
redirect: 'error'
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
throw new Error(`SiteShield event API returned ${response.status}`);
|
|
73
|
+
};
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "siteshield",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable bot-protection engine and Next.js adapter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=20" },
|
|
7
|
+
"publishConfig": { "access": "public" },
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"exports": {
|
|
10
|
+
"./next": {
|
|
11
|
+
"types": "./dist/integrations/next.d.ts",
|
|
12
|
+
"import": "./dist/integrations/next.js"
|
|
13
|
+
},
|
|
14
|
+
"./core": {
|
|
15
|
+
"types": "./dist/core/engine.d.ts",
|
|
16
|
+
"import": "./dist/core/engine.js"
|
|
17
|
+
},
|
|
18
|
+
"./counters": {
|
|
19
|
+
"types": "./dist/core/counters.d.ts",
|
|
20
|
+
"import": "./dist/core/counters.js"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|