@consentify/core 0.1.0 → 1.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 +245 -79
- package/dist/index.d.ts +7 -0
- package/dist/index.js +62 -9
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -1,126 +1,292 @@
|
|
|
1
|
-
|
|
1
|
+
# @consentify/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@consentify/core)
|
|
4
|
+
[](https://www.npmjs.com/package/@consentify/core)
|
|
5
|
+
[](https://bundlephobia.com/package/@consentify/core)
|
|
6
|
+
[](./LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
> Headless cookie consent SDK — zero dependencies, TypeScript-first, SSR-ready.
|
|
9
|
+
|
|
10
|
+
## Why Consentify?
|
|
11
|
+
|
|
12
|
+
- **🪶 Lightweight** — Zero runtime dependencies, ~2KB minified + gzipped
|
|
13
|
+
- **🔒 Type-safe** — Full TypeScript support with inference for your categories
|
|
14
|
+
- **⚡ SSR-ready** — Separate server/client APIs that never touch the DOM on server
|
|
15
|
+
- **⚛️ React-ready** — Built-in `useSyncExternalStore` support for React 18+
|
|
16
|
+
- **🎯 Headless** — Bring your own UI, we handle the state
|
|
17
|
+
- **📋 Compliant** — Built for GDPR, CCPA, and similar regulations
|
|
18
|
+
|
|
19
|
+
## Install
|
|
6
20
|
|
|
7
21
|
```bash
|
|
8
22
|
npm install @consentify/core
|
|
23
|
+
# or
|
|
24
|
+
pnpm add @consentify/core
|
|
25
|
+
# or
|
|
26
|
+
yarn add @consentify/core
|
|
9
27
|
```
|
|
10
28
|
|
|
11
|
-
|
|
29
|
+
## Quick Start
|
|
12
30
|
|
|
13
31
|
```ts
|
|
14
32
|
import { createConsentify, defaultCategories } from '@consentify/core';
|
|
15
33
|
|
|
16
|
-
const
|
|
34
|
+
const consent = createConsentify({
|
|
17
35
|
policy: {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
identifier: 'policy-v1',
|
|
21
|
-
categories: defaultCategories,
|
|
36
|
+
identifier: 'v1.0',
|
|
37
|
+
categories: defaultCategories, // ['preferences', 'analytics', 'marketing', 'functional', 'unclassified']
|
|
22
38
|
},
|
|
23
|
-
cookie: {
|
|
24
|
-
name: 'consentify',
|
|
25
|
-
path: '/',
|
|
26
|
-
sameSite: 'Lax',
|
|
27
|
-
secure: true,
|
|
28
|
-
},
|
|
29
|
-
// Cookie is canonical. Optionally mirror to localStorage for fast client reads.
|
|
30
|
-
// storage: ['localStorage', 'cookie']
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
//
|
|
34
|
-
|
|
41
|
+
// Set user choices
|
|
42
|
+
consent.client.set({ analytics: true, marketing: false });
|
|
43
|
+
|
|
44
|
+
// Check consent
|
|
45
|
+
if (consent.client.get('analytics')) {
|
|
46
|
+
loadAnalytics();
|
|
47
|
+
}
|
|
35
48
|
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
|
|
49
|
+
// Get full state
|
|
50
|
+
const state = consent.client.get();
|
|
51
|
+
// → { decision: 'decided', snapshot: { policy: '...', givenAt: '...', choices: {...} } }
|
|
52
|
+
// → { decision: 'unset' } (if no consent given yet)
|
|
39
53
|
```
|
|
40
54
|
|
|
41
|
-
|
|
55
|
+
## React Integration
|
|
42
56
|
|
|
43
|
-
|
|
57
|
+
For React projects, use the [`@consentify/react`](https://www.npmjs.com/package/@consentify/react) package which provides a ready-to-use hook:
|
|
44
58
|
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const canAnalytics = state.decision === 'decided' && !!state.snapshot.choices.analytics;
|
|
59
|
+
```bash
|
|
60
|
+
npm install @consentify/react
|
|
61
|
+
```
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
```tsx
|
|
64
|
+
import { createConsentify, defaultCategories, useConsentify } from '@consentify/react';
|
|
65
|
+
|
|
66
|
+
const consent = createConsentify({
|
|
67
|
+
policy: { identifier: 'v1.0', categories: defaultCategories },
|
|
68
|
+
});
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
const
|
|
70
|
+
function CookieBanner() {
|
|
71
|
+
const state = useConsentify(consent);
|
|
72
|
+
|
|
73
|
+
if (state.decision === 'decided') return null;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="cookie-banner">
|
|
77
|
+
<p>We use cookies to enhance your experience.</p>
|
|
78
|
+
<button onClick={() => consent.client.set({
|
|
79
|
+
analytics: true,
|
|
80
|
+
marketing: true,
|
|
81
|
+
preferences: true
|
|
82
|
+
})}>
|
|
83
|
+
Accept All
|
|
84
|
+
</button>
|
|
85
|
+
<button onClick={() => consent.client.set({
|
|
86
|
+
analytics: false,
|
|
87
|
+
marketing: false,
|
|
88
|
+
preferences: false
|
|
89
|
+
})}>
|
|
90
|
+
Essential Only
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
56
95
|
```
|
|
57
96
|
|
|
58
|
-
|
|
97
|
+
<details>
|
|
98
|
+
<summary>Manual integration with useSyncExternalStore</summary>
|
|
59
99
|
|
|
60
|
-
|
|
100
|
+
If you prefer not to add the React package, you can use `useSyncExternalStore` directly:
|
|
61
101
|
|
|
62
|
-
|
|
102
|
+
```tsx
|
|
103
|
+
import { useSyncExternalStore } from 'react';
|
|
104
|
+
import { createConsentify, defaultCategories } from '@consentify/core';
|
|
63
105
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- `get(category: 'necessary' | T): boolean` — boolean check; `'necessary'` always returns `true`.
|
|
68
|
-
- `set(choices: Partial<Choices<T>>): void` — merges and saves; writes only if changed.
|
|
69
|
-
- `clear(): void` — removes stored consent (cookie and any mirror).
|
|
70
|
-
- `server`:
|
|
71
|
-
- `get(cookieHeader: string | null | undefined): ConsentState<T>` — raw Cookie header in, state out.
|
|
72
|
-
- `set(choices: Partial<Choices<T>>, currentCookieHeader?: string): string` — returns `Set-Cookie` header string.
|
|
73
|
-
- `clear(): string` — returns `Set-Cookie` header string to delete the cookie.
|
|
106
|
+
const consent = createConsentify({
|
|
107
|
+
policy: { identifier: 'v1.0', categories: defaultCategories },
|
|
108
|
+
});
|
|
74
109
|
|
|
75
|
-
|
|
110
|
+
function useConsent() {
|
|
111
|
+
return useSyncExternalStore(
|
|
112
|
+
consent.client.subscribe,
|
|
113
|
+
consent.client.get,
|
|
114
|
+
consent.client.getServerSnapshot
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
76
118
|
|
|
77
|
-
|
|
78
|
-
- `identifier` is recommended and should come from your actual policy version/content. If omitted, a deterministic hash of the categories is used.
|
|
79
|
-
- `cookie`:
|
|
80
|
-
- `name?: string` (default: `consentify`)
|
|
81
|
-
- `maxAgeSec?: number` (default: 1 year)
|
|
82
|
-
- `sameSite?: 'Lax' | 'Strict' | 'None'` (default: `'Lax'`)
|
|
83
|
-
- `secure?: boolean` (forced `true` when `sameSite==='None'`)
|
|
84
|
-
- `path?: string` (default: `/`)
|
|
85
|
-
- `domain?: string`
|
|
86
|
-
- `storage?: ('cookie' | 'localStorage')[]` (default: `['cookie']`)
|
|
119
|
+
</details>
|
|
87
120
|
|
|
88
|
-
|
|
121
|
+
## Server-Side Usage
|
|
89
122
|
|
|
90
|
-
|
|
91
|
-
- The snapshot is invalidated automatically when the policy identity changes (identifier/hash).
|
|
123
|
+
The server API works with raw `Cookie` headers — perfect for Next.js, Remix, or any Node.js framework:
|
|
92
124
|
|
|
93
|
-
|
|
125
|
+
```ts
|
|
126
|
+
// Read consent from request
|
|
127
|
+
const state = consent.server.get(request.headers.get('cookie'));
|
|
128
|
+
|
|
129
|
+
if (state.decision === 'decided' && state.snapshot.choices.analytics) {
|
|
130
|
+
// User consented to analytics
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Set consent (returns Set-Cookie header string)
|
|
134
|
+
const setCookieHeader = consent.server.set(
|
|
135
|
+
{ analytics: true },
|
|
136
|
+
request.headers.get('cookie')
|
|
137
|
+
);
|
|
138
|
+
response.headers.set('Set-Cookie', setCookieHeader);
|
|
139
|
+
|
|
140
|
+
// Clear consent
|
|
141
|
+
const clearHeader = consent.server.clear();
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Next.js App Router Example
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
// lib/consent.ts
|
|
148
|
+
import { createConsentify, defaultCategories } from '@consentify/core';
|
|
149
|
+
|
|
150
|
+
export const consent = createConsentify({
|
|
151
|
+
policy: { identifier: 'v1.0', categories: defaultCategories },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// app/layout.tsx
|
|
155
|
+
import { cookies } from 'next/headers';
|
|
156
|
+
import { consent } from '@/lib/consent';
|
|
157
|
+
|
|
158
|
+
export default async function RootLayout({ children }) {
|
|
159
|
+
const cookieStore = await cookies();
|
|
160
|
+
const state = consent.server.get(cookieStore.toString());
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<html>
|
|
164
|
+
<body>
|
|
165
|
+
{children}
|
|
166
|
+
{state.decision === 'decided' && state.snapshot.choices.analytics && (
|
|
167
|
+
<Analytics />
|
|
168
|
+
)}
|
|
169
|
+
</body>
|
|
170
|
+
</html>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Custom Categories
|
|
94
176
|
|
|
95
|
-
|
|
96
|
-
- `Snapshot<T>` — `{ policy: string; givenAt: string; choices: Record<'necessary'|T, boolean> }`.
|
|
97
|
-
- `Choices<T>` — map of consent by category plus `'necessary'`.
|
|
98
|
-
- `ConsentState<T>` — `{ decision: 'decided', snapshot } | { decision: 'unset' }`.
|
|
99
|
-
- `defaultCategories`/`DefaultCategory` — reusable defaults.
|
|
177
|
+
Define your own consent categories with full type safety:
|
|
100
178
|
|
|
101
|
-
|
|
179
|
+
```ts
|
|
180
|
+
const consent = createConsentify({
|
|
181
|
+
policy: {
|
|
182
|
+
identifier: 'v1.0',
|
|
183
|
+
categories: ['analytics', 'ads', 'personalization'] as const,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// TypeScript knows your categories!
|
|
188
|
+
consent.client.set({ analytics: true, ads: false });
|
|
189
|
+
consent.client.get('personalization'); // ✓ valid
|
|
190
|
+
consent.client.get('unknown'); // ✗ type error
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Configuration
|
|
102
194
|
|
|
103
195
|
```ts
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
196
|
+
createConsentify({
|
|
197
|
+
policy: {
|
|
198
|
+
identifier: 'v1.0', // Recommended: version your policy
|
|
199
|
+
categories: defaultCategories,
|
|
200
|
+
},
|
|
201
|
+
// Consent validity (when to re-prompt user)
|
|
202
|
+
consentMaxAgeDays: 365, // Optional: re-consent after N days
|
|
203
|
+
// Cookie storage settings (browser retention)
|
|
204
|
+
cookie: {
|
|
205
|
+
name: 'consent', // Default: 'consentify'
|
|
206
|
+
maxAgeSec: 60 * 60 * 24 * 365, // Default: 1 year (browser storage)
|
|
207
|
+
sameSite: 'Lax', // 'Lax' | 'Strict' | 'None'
|
|
208
|
+
secure: true, // Forced true when sameSite='None'
|
|
209
|
+
path: '/',
|
|
210
|
+
domain: '.example.com', // Optional: for cross-subdomain
|
|
211
|
+
},
|
|
212
|
+
storage: ['cookie'], // ['cookie'] | ['localStorage', 'cookie']
|
|
107
213
|
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## API Reference
|
|
217
|
+
|
|
218
|
+
### `createConsentify(options)`
|
|
219
|
+
|
|
220
|
+
Returns an object with `policy`, `client`, and `server` properties.
|
|
221
|
+
|
|
222
|
+
#### `client` (browser)
|
|
108
223
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
224
|
+
| Method | Description |
|
|
225
|
+
|--------|-------------|
|
|
226
|
+
| `get()` | Returns `ConsentState` — `{ decision: 'decided', snapshot }` or `{ decision: 'unset' }` |
|
|
227
|
+
| `get(category)` | Returns `boolean` — `true` if category is consented (`'necessary'` always returns `true`) |
|
|
228
|
+
| `set(choices)` | Merges choices and persists; notifies subscribers if changed |
|
|
229
|
+
| `clear()` | Removes stored consent; notifies subscribers |
|
|
230
|
+
| `subscribe(cb)` | Subscribe to changes; returns unsubscribe function |
|
|
231
|
+
| `getServerSnapshot()` | Returns `{ decision: 'unset' }` for SSR hydration |
|
|
232
|
+
|
|
233
|
+
#### `server` (Node.js)
|
|
234
|
+
|
|
235
|
+
| Method | Description |
|
|
236
|
+
|--------|-------------|
|
|
237
|
+
| `get(cookieHeader)` | Parse consent from `Cookie` header string |
|
|
238
|
+
| `set(choices, cookieHeader?)` | Returns `Set-Cookie` header string |
|
|
239
|
+
| `clear()` | Returns `Set-Cookie` header string to delete cookie |
|
|
240
|
+
|
|
241
|
+
### Types
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
type ConsentState<T> =
|
|
245
|
+
| { decision: 'unset' }
|
|
246
|
+
| { decision: 'decided'; snapshot: Snapshot<T> };
|
|
247
|
+
|
|
248
|
+
interface Snapshot<T> {
|
|
249
|
+
policy: string; // Policy identifier/hash
|
|
250
|
+
givenAt: string; // ISO timestamp
|
|
251
|
+
choices: Choices<T>; // { necessary: true, ...categories }
|
|
112
252
|
}
|
|
253
|
+
|
|
254
|
+
type Choices<T> = Record<'necessary' | T, boolean>;
|
|
113
255
|
```
|
|
114
256
|
|
|
115
|
-
###
|
|
257
|
+
### Default Categories
|
|
116
258
|
|
|
117
|
-
|
|
259
|
+
```ts
|
|
260
|
+
const defaultCategories = [
|
|
261
|
+
'preferences', // User preferences (language, theme)
|
|
262
|
+
'analytics', // Analytics and performance
|
|
263
|
+
'marketing', // Advertising and marketing
|
|
264
|
+
'functional', // Enhanced functionality
|
|
265
|
+
'unclassified', // Uncategorized cookies
|
|
266
|
+
] as const;
|
|
267
|
+
```
|
|
118
268
|
|
|
119
|
-
|
|
120
|
-
- ☕ [Ko-fi](https://ko-fi.com/romandenysov)
|
|
269
|
+
## How It Works
|
|
121
270
|
|
|
122
|
-
|
|
271
|
+
1. **Policy versioning** — Consent is tied to a policy identifier. When you update your policy (change `identifier`), previous consent is invalidated.
|
|
123
272
|
|
|
124
|
-
|
|
273
|
+
2. **Necessary cookies** — The `'necessary'` category is always `true` and cannot be disabled.
|
|
274
|
+
|
|
275
|
+
3. **Storage** — Cookie is the canonical store (works on server). Optionally mirror to `localStorage` for faster client reads.
|
|
276
|
+
|
|
277
|
+
4. **Compact format** — Consent is stored as a URL-encoded JSON snapshot in a single cookie.
|
|
278
|
+
|
|
279
|
+
5. **Consent expiration** — Optional `consentMaxAgeDays` invalidates consent after N days, requiring users to re-consent. This is independent of `cookie.maxAgeSec` (which controls how long the browser stores the cookie).
|
|
125
280
|
|
|
281
|
+
## Support
|
|
126
282
|
|
|
283
|
+
If you find this library useful:
|
|
284
|
+
|
|
285
|
+
- ⭐ Star the repo on [GitHub](https://github.com/RomanDenysov/consentify)
|
|
286
|
+
- 💖 [Sponsor on GitHub](https://github.com/sponsors/RomanDenysov)
|
|
287
|
+
- ☕ [Buy me a coffee on Ko-fi](https://ko-fi.com/romandenysov)
|
|
288
|
+
- ☕ [Buy me a coffee](https://buymeacoffee.com/romandenysov)
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
MIT © 2025 [Roman Denysov](https://github.com/RomanDenysov)
|
package/dist/index.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export interface CreateConsentifyInit<Cs extends readonly string[]> {
|
|
|
59
59
|
path?: string;
|
|
60
60
|
domain?: string;
|
|
61
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* Maximum age of consent in days. If set, consent older than this
|
|
64
|
+
* will be treated as expired, requiring re-consent.
|
|
65
|
+
*/
|
|
66
|
+
consentMaxAgeDays?: number;
|
|
62
67
|
/**
|
|
63
68
|
* Client-side storage priority. Server-side access is cookie-only.
|
|
64
69
|
* Supported: 'cookie' (canonical), 'localStorage' (optional mirror for fast reads)
|
|
@@ -83,6 +88,8 @@ export declare function createConsentify<Cs extends readonly string[]>(init: Cre
|
|
|
83
88
|
};
|
|
84
89
|
set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
|
|
85
90
|
clear: () => void;
|
|
91
|
+
subscribe: (callback: () => void) => (() => void);
|
|
92
|
+
getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
|
|
86
93
|
};
|
|
87
94
|
};
|
|
88
95
|
export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,13 @@ catch {
|
|
|
29
29
|
return null;
|
|
30
30
|
} };
|
|
31
31
|
const toISO = () => new Date().toISOString();
|
|
32
|
+
function isValidSnapshot(s) {
|
|
33
|
+
return (typeof s === 'object' && s !== null &&
|
|
34
|
+
typeof s.policy === 'string' &&
|
|
35
|
+
typeof s.givenAt === 'string' &&
|
|
36
|
+
typeof s.choices === 'object' &&
|
|
37
|
+
s.choices !== null);
|
|
38
|
+
}
|
|
32
39
|
function readCookie(name, cookieStr) {
|
|
33
40
|
const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
|
|
34
41
|
if (!src)
|
|
@@ -59,6 +66,16 @@ export function createConsentify(init) {
|
|
|
59
66
|
domain: init.cookie?.domain,
|
|
60
67
|
};
|
|
61
68
|
const storageOrder = (init.storage && init.storage.length > 0) ? init.storage : ['cookie'];
|
|
69
|
+
const consentMaxAgeDays = init.consentMaxAgeDays;
|
|
70
|
+
const isExpired = (givenAt) => {
|
|
71
|
+
if (!consentMaxAgeDays)
|
|
72
|
+
return false;
|
|
73
|
+
const givenTime = new Date(givenAt).getTime();
|
|
74
|
+
if (isNaN(givenTime))
|
|
75
|
+
return true; // Invalid date = expired
|
|
76
|
+
const maxAgeMs = consentMaxAgeDays * 24 * 60 * 60 * 1000;
|
|
77
|
+
return Date.now() - givenTime > maxAgeMs;
|
|
78
|
+
};
|
|
62
79
|
const allowed = new Set(['necessary', ...init.policy.categories]);
|
|
63
80
|
const normalize = (choices) => {
|
|
64
81
|
const base = { necessary: true };
|
|
@@ -131,7 +148,6 @@ export function createConsentify(init) {
|
|
|
131
148
|
const writeClientRaw = (value) => {
|
|
132
149
|
const primary = firstAvailableStore();
|
|
133
150
|
writeToStore(primary, value);
|
|
134
|
-
// Mirror to cookie if requested anywhere in order and not already writing cookie
|
|
135
151
|
if (primary !== 'cookie' && storageOrder.includes('cookie'))
|
|
136
152
|
writeToStore('cookie', value);
|
|
137
153
|
};
|
|
@@ -139,10 +155,12 @@ export function createConsentify(init) {
|
|
|
139
155
|
const readClient = () => {
|
|
140
156
|
const raw = readClientRaw();
|
|
141
157
|
const s = raw ? dec(raw) : null;
|
|
142
|
-
if (!s)
|
|
158
|
+
if (!s || !isValidSnapshot(s))
|
|
143
159
|
return null;
|
|
144
160
|
if (s.policy !== policyHash)
|
|
145
161
|
return null;
|
|
162
|
+
if (isExpired(s.givenAt))
|
|
163
|
+
return null;
|
|
146
164
|
return s;
|
|
147
165
|
};
|
|
148
166
|
const writeClientIfChanged = (next) => {
|
|
@@ -165,10 +183,12 @@ export function createConsentify(init) {
|
|
|
165
183
|
get: (cookieHeader) => {
|
|
166
184
|
const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
|
|
167
185
|
const s = raw ? dec(raw) : null;
|
|
168
|
-
if (!s)
|
|
186
|
+
if (!s || !isValidSnapshot(s))
|
|
169
187
|
return { decision: 'unset' };
|
|
170
188
|
if (s.policy !== policyHash)
|
|
171
189
|
return { decision: 'unset' };
|
|
190
|
+
if (isExpired(s.givenAt))
|
|
191
|
+
return { decision: 'unset' };
|
|
172
192
|
return { decision: 'decided', snapshot: s };
|
|
173
193
|
},
|
|
174
194
|
set: (choices, currentCookieHeader) => {
|
|
@@ -190,14 +210,35 @@ export function createConsentify(init) {
|
|
|
190
210
|
return h;
|
|
191
211
|
}
|
|
192
212
|
};
|
|
193
|
-
|
|
213
|
+
// ========== NEW: Subscribe pattern for React ==========
|
|
214
|
+
const listeners = new Set();
|
|
215
|
+
const unsetState = { decision: 'unset' };
|
|
216
|
+
let cachedState = unsetState;
|
|
217
|
+
const syncState = () => {
|
|
194
218
|
const s = readClient();
|
|
195
|
-
|
|
219
|
+
if (!s) {
|
|
220
|
+
cachedState = unsetState;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
cachedState = { decision: 'decided', snapshot: s };
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const notifyListeners = () => {
|
|
227
|
+
listeners.forEach(cb => cb());
|
|
228
|
+
};
|
|
229
|
+
// Init cache on browser
|
|
230
|
+
if (isBrowser()) {
|
|
231
|
+
syncState();
|
|
232
|
+
}
|
|
233
|
+
function clientGet(category) {
|
|
234
|
+
// Return cached state for React compatibility
|
|
196
235
|
if (typeof category === 'undefined')
|
|
197
|
-
return
|
|
236
|
+
return cachedState;
|
|
198
237
|
if (category === 'necessary')
|
|
199
238
|
return true;
|
|
200
|
-
return
|
|
239
|
+
return cachedState.decision === 'decided'
|
|
240
|
+
? !!cachedState.snapshot.choices[category]
|
|
241
|
+
: false;
|
|
201
242
|
}
|
|
202
243
|
const client = {
|
|
203
244
|
get: clientGet,
|
|
@@ -209,14 +250,26 @@ export function createConsentify(init) {
|
|
|
209
250
|
givenAt: toISO(),
|
|
210
251
|
choices: normalize({ ...base, ...choices }),
|
|
211
252
|
};
|
|
212
|
-
writeClientIfChanged(next);
|
|
253
|
+
const changed = writeClientIfChanged(next);
|
|
254
|
+
if (changed) {
|
|
255
|
+
syncState();
|
|
256
|
+
notifyListeners();
|
|
257
|
+
}
|
|
213
258
|
},
|
|
214
259
|
clear: () => {
|
|
215
260
|
for (const k of new Set([...storageOrder, 'cookie']))
|
|
216
261
|
clearStore(k);
|
|
262
|
+
syncState();
|
|
263
|
+
notifyListeners();
|
|
264
|
+
},
|
|
265
|
+
// NEW: Subscribe for React useSyncExternalStore
|
|
266
|
+
subscribe: (callback) => {
|
|
267
|
+
listeners.add(callback);
|
|
268
|
+
return () => listeners.delete(callback);
|
|
217
269
|
},
|
|
270
|
+
// NEW: Server snapshot for SSR (always unset)
|
|
271
|
+
getServerSnapshot: () => unsetState,
|
|
218
272
|
};
|
|
219
|
-
// ---- single entry (DX aliases)
|
|
220
273
|
return {
|
|
221
274
|
policy: {
|
|
222
275
|
categories: init.policy.categories,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consentify/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Minimal headless cookie consent SDK (TypeScript, SSR-ready, lazy-init).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Roman Denysov",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
{
|
|
15
15
|
"type": "ko-fi",
|
|
16
16
|
"url": "https://ko-fi.com/romandenysov"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"type": "buymeacoffee",
|
|
20
|
+
"url": "https://buymeacoffee.com/romandenysov"
|
|
17
21
|
}
|
|
18
22
|
],
|
|
19
23
|
"engines": {
|
|
@@ -63,6 +67,7 @@
|
|
|
63
67
|
"@types/node": "24.7.0"
|
|
64
68
|
},
|
|
65
69
|
"scripts": {
|
|
66
|
-
"build": "rimraf dist && tsc -p tsconfig.build.json"
|
|
70
|
+
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
|
71
|
+
"check": "tsc --noEmit"
|
|
67
72
|
}
|
|
68
73
|
}
|