statswhatshesaid 0.1.0 → 0.2.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 +71 -186
- package/dist/index.cjs +99 -333
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -71
- package/dist/index.d.ts +40 -71
- package/dist/index.js +97 -332
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# statswhatshesaid
|
|
2
2
|
|
|
3
|
-
A super minimal drop-in stats library for
|
|
3
|
+
A super minimal **one-line** drop-in stats library for Next.js. One metric, one line of integration, **zero runtime dependencies**, in-memory only, runs in **both** the Edge and Node runtimes.
|
|
4
4
|
|
|
5
5
|
- Tracks **unique visitors per day** — that's it.
|
|
6
6
|
- No tracking pixel, no client JS, no cookies.
|
|
7
|
-
- **Zero dependencies.** No native modules, no SQLite, no Docker volume gymnastics.
|
|
8
|
-
-
|
|
7
|
+
- **Zero dependencies.** No native modules, no filesystem, no SQLite, no Docker volume gymnastics.
|
|
8
|
+
- **Works anywhere.** Edge runtime, Node runtime, Vercel, self-hosted, Docker, scratch images. The library uses only Web APIs (`crypto.subtle`, `crypto.getRandomValues`, `globalThis.fetch`).
|
|
9
9
|
- Read your stats by visiting `myapp.com/stats?t=<your-secret>` — JSON response.
|
|
10
10
|
|
|
11
|
-
> **Designed for freshly launched apps.**
|
|
11
|
+
> **Designed for freshly launched apps.** Counts and history live in process memory. They survive across requests within a single worker but reset on every deploy / restart. That's the trade-off for "drop in and forget." Once your traffic warrants real analytics, graduate to Plausible / Umami / PostHog.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -18,21 +18,14 @@ npm install statswhatshesaid
|
|
|
18
18
|
|
|
19
19
|
## Use it
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
**One line.** That's it.
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
24
|
// middleware.ts
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export default stats.middleware()
|
|
28
|
-
|
|
29
|
-
export const config = {
|
|
30
|
-
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
31
|
-
runtime: 'nodejs', // REQUIRED — see "Edge Runtime" below
|
|
32
|
-
}
|
|
25
|
+
export { default } from 'statswhatshesaid'
|
|
33
26
|
```
|
|
34
27
|
|
|
35
|
-
Set your secret
|
|
28
|
+
Set your secret:
|
|
36
29
|
|
|
37
30
|
```bash
|
|
38
31
|
STATS_TOKEN=pick-a-long-random-string
|
|
@@ -57,34 +50,50 @@ You'll get JSON back:
|
|
|
57
50
|
}
|
|
58
51
|
```
|
|
59
52
|
|
|
60
|
-
That's the whole library.
|
|
53
|
+
That's the whole library. No `runtime: 'nodejs'` config, no `matcher`, no `experimental`, no `next.config` flags. Just one re-export line.
|
|
61
54
|
|
|
62
|
-
##
|
|
55
|
+
## Customizing options
|
|
63
56
|
|
|
64
|
-
|
|
57
|
+
If you need to change defaults — bot filter, endpoint path, history retention, trustProxy hops — import `createMiddleware` instead:
|
|
65
58
|
|
|
66
59
|
```ts
|
|
60
|
+
// middleware.ts
|
|
61
|
+
import { createMiddleware } from 'statswhatshesaid'
|
|
62
|
+
|
|
63
|
+
export default createMiddleware({
|
|
64
|
+
endpointPath: '/_internal/stats',
|
|
65
|
+
filterBots: false,
|
|
66
|
+
trustProxy: 2,
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You can also set a custom `matcher` if you want the middleware to run on a narrower path set than "everything":
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createMiddleware } from 'statswhatshesaid'
|
|
74
|
+
|
|
75
|
+
export default createMiddleware()
|
|
76
|
+
|
|
67
77
|
export const config = {
|
|
68
|
-
matcher: [
|
|
69
|
-
runtime: 'nodejs',
|
|
78
|
+
matcher: ['/((?!api).*)'],
|
|
70
79
|
}
|
|
71
80
|
```
|
|
72
81
|
|
|
73
|
-
This is stable in **Next.js 15.2 and newer**.
|
|
74
|
-
|
|
75
82
|
## How a "unique visitor" is counted
|
|
76
83
|
|
|
77
84
|
Cookieless, Plausible-style:
|
|
78
85
|
|
|
79
86
|
```
|
|
80
|
-
visitorHash = SHA-256( ip +
|
|
87
|
+
visitorHash = SHA-256( length-prefixed( ip ) + length-prefixed( userAgent ) + dailySalt )
|
|
81
88
|
```
|
|
82
89
|
|
|
83
|
-
- `dailySalt` is generated in process memory and
|
|
90
|
+
- `dailySalt` is generated in process memory at startup and rotated lazily at every UTC midnight.
|
|
84
91
|
- The hash is fed into a [**HyperLogLog** sketch](https://en.wikipedia.org/wiki/HyperLogLog) with 16384 one-byte registers (16 KB fixed per day, forever).
|
|
85
|
-
- At UTC midnight the day's estimate is
|
|
92
|
+
- At UTC midnight the day's estimate is moved to an in-memory historical map and the sketch is reset with a fresh salt.
|
|
86
93
|
- Cross-day unlinkability: because the salt is regenerated, hashes from different days can't be correlated back to the same visitor.
|
|
94
|
+
- The hash inputs are length-prefixed so two distinct `(ip, ua)` pairs can never collide via separator ambiguity.
|
|
87
95
|
- Common bot User-Agents are filtered out by default.
|
|
96
|
+
- Common static asset paths (`/_next/static/*`, `/_next/image/*`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml`, `/manifest.json`, etc.) are filtered out before tracking, so you don't need a custom `matcher`.
|
|
88
97
|
|
|
89
98
|
### About accuracy
|
|
90
99
|
|
|
@@ -94,122 +103,45 @@ If you need exact counts down to the last human, don't use this library — grad
|
|
|
94
103
|
|
|
95
104
|
## Storage
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```jsonc
|
|
100
|
-
{
|
|
101
|
-
"version": 1,
|
|
102
|
-
"today": "2026-04-07",
|
|
103
|
-
"salt": "<base64 32 bytes>",
|
|
104
|
-
"hllRegisters": "<base64 16 KB>",
|
|
105
|
-
"history": { "2026-04-06": 388, "2026-04-05": 401 }
|
|
106
|
-
}
|
|
107
|
-
```
|
|
106
|
+
**There is none.** Counts and history live in module-level memory inside whichever Next.js worker is running your middleware.
|
|
108
107
|
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
- **Flushed every hour** (tunable) and on `SIGTERM`/`SIGINT`/`beforeExit`.
|
|
113
|
-
- **Nothing on the hot path touches disk.** Tracking a visit is: one SHA-256, one HLL register update. Sub-millisecond.
|
|
108
|
+
- ✅ State **survives across requests** within a single worker / Edge isolate (which is what makes the counter actually count).
|
|
109
|
+
- ❌ State is **lost on every deploy**, process restart, or worker recycle.
|
|
110
|
+
- ❌ State is **per-instance**: if you're running multiple replicas behind a load balancer, each replica has its own counter and they don't sync. Run a single instance, or use a real analytics tool.
|
|
114
111
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Because it's one small file, you have options:
|
|
118
|
-
|
|
119
|
-
```dockerfile
|
|
120
|
-
# Option A: persist it on a volume
|
|
121
|
-
VOLUME /data
|
|
122
|
-
ENV STATS_SNAPSHOT_PATH=/data/stats.json
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
```dockerfile
|
|
126
|
-
# Option B: bind-mount a single file from the host
|
|
127
|
-
# docker run -v $(pwd)/stats.json:/app/stats.json \
|
|
128
|
-
# -e STATS_SNAPSHOT_PATH=/app/stats.json ...
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
```dockerfile
|
|
132
|
-
# Option C: accept ephemerality. Losing "today" on a redeploy is often fine
|
|
133
|
-
# for a small app. The snapshot is flushed on SIGTERM when Node is PID 1,
|
|
134
|
-
# so graceful stops keep the latest data.
|
|
135
|
-
ENV STATS_SNAPSHOT_PATH=/tmp/statswhatshesaid.json
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Works fine on `node:20-alpine`, `node:20-slim`, distroless — there are no native modules to compile.
|
|
139
|
-
|
|
140
|
-
### Bring your own backend
|
|
141
|
-
|
|
142
|
-
If you want to stash the snapshot in Redis, Vercel KV, S3, or anything else, pass a `persist` adapter:
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
import stats from 'statswhatshesaid'
|
|
146
|
-
import type { PersistAdapter, SnapshotV1 } from 'statswhatshesaid'
|
|
147
|
-
|
|
148
|
-
const redisPersist: PersistAdapter = {
|
|
149
|
-
load: () => {
|
|
150
|
-
const raw = redisClient.get('statswhatshesaid:snap') // your sync/blocking client
|
|
151
|
-
return raw ? (JSON.parse(raw) as SnapshotV1) : null
|
|
152
|
-
},
|
|
153
|
-
save: (snap) => {
|
|
154
|
-
redisClient.set('statswhatshesaid:snap', JSON.stringify(snap))
|
|
155
|
-
},
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export default stats.middleware({ persist: redisPersist })
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
The adapter interface is synchronous on purpose so the shutdown handler can flush deterministically.
|
|
112
|
+
This is intentional. The library exists to give freshly launched apps an "is anybody home?" signal in 30 seconds with zero infrastructure. Persistence and replication are a different problem class — graduate when you need them.
|
|
162
113
|
|
|
163
114
|
## Configuration
|
|
164
115
|
|
|
165
|
-
Configure via env vars (preferred) or by passing options to `
|
|
116
|
+
Configure via env vars (preferred for `STATS_TOKEN`) or by passing options to `createMiddleware({...})`. Options override env.
|
|
166
117
|
|
|
167
118
|
| Option | Env var | Default |
|
|
168
119
|
| --- | --- | --- |
|
|
169
120
|
| `token` | `STATS_TOKEN` | **required** |
|
|
170
|
-
| `snapshotPath` | `STATS_SNAPSHOT_PATH` | `./.statswhatshesaid.json` |
|
|
171
|
-
| `persist` | — | file adapter at `snapshotPath` |
|
|
172
|
-
| `flushIntervalMs` | `STATS_FLUSH_INTERVAL_MS` | `3600000` (1 hour) |
|
|
173
121
|
| `endpointPath` | `STATS_ENDPOINT_PATH` | `/stats` |
|
|
174
122
|
| `historyDays` | — | `90` (returned from `/stats`) |
|
|
175
|
-
| `maxHistoryDays` | — | `365` (kept in
|
|
123
|
+
| `maxHistoryDays` | — | `365` (kept in memory) |
|
|
176
124
|
| `filterBots` | — | `true` |
|
|
177
125
|
| `trustProxy` | `STATS_TRUST_PROXY` | `1` (see [Security](#security) below) |
|
|
178
126
|
|
|
179
|
-
```ts
|
|
180
|
-
export default stats.middleware({
|
|
181
|
-
endpointPath: '/_internal/stats',
|
|
182
|
-
flushIntervalMs: 5 * 60 * 1000,
|
|
183
|
-
historyDays: 30,
|
|
184
|
-
trustProxy: 1,
|
|
185
|
-
})
|
|
186
|
-
```
|
|
187
|
-
|
|
188
127
|
## Security
|
|
189
128
|
|
|
190
|
-
This is a minimal library, but it runs inside your app's request path
|
|
129
|
+
This is a minimal library, but it runs inside your app's request path, so its defaults matter. Read this section before deploying.
|
|
191
130
|
|
|
192
131
|
### Threat model
|
|
193
132
|
|
|
194
133
|
- **In scope:** preventing trivial forging of visitor counts, protecting the `/stats` endpoint from unauthorized reads, keeping the process alive under abuse, making visitor hashes cross-day unlinkable.
|
|
195
|
-
- **Out of scope:** preventing a determined attacker with unlimited resources from skewing the numbers. statswhatshesaid is for day-one visibility on small
|
|
134
|
+
- **Out of scope:** preventing a determined attacker with unlimited resources from skewing the numbers. statswhatshesaid is for day-one visibility on small apps. Once your traffic is big enough that someone would bother flooding your stats, you should be on Plausible / Umami / PostHog anyway.
|
|
196
135
|
|
|
197
136
|
### 1. `trustProxy` — who decides the client IP?
|
|
198
137
|
|
|
199
138
|
Unique-visitor dedup hashes the client IP alongside the User-Agent. If the attacker controls the IP you hash with, they control the count.
|
|
200
139
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
To pick the real client IP safely you must **walk the chain from the right, skipping one entry per trusted proxy**.
|
|
204
|
-
|
|
205
|
-
**Configuration:**
|
|
206
|
-
|
|
207
|
-
- `trustProxy: 0` — Never read forwarding headers. Every request hashes to a single constant peer. `uniqueVisitors` will under-count dramatically (ideally it collapses to 1), but **nothing an attacker sends can forge it**. Use this only if (a) your process is directly exposed to untrusted clients, or (b) you're OK with a "did anybody visit today?" binary signal.
|
|
208
|
-
|
|
209
|
-
- `trustProxy: 1` **(default)** — One trusted reverse proxy sits in front of this Node process. The library takes the **rightmost** entry of `X-Forwarded-For`. This is correct for the single most common self-hosted shape: `client → nginx → next`, or `client → Caddy → next`, or `client → Traefik → next`.
|
|
210
|
-
|
|
211
|
-
- `trustProxy: 2` — Two trusted hops. The library takes the **second-from-right** entry of `X-Forwarded-For`. Use this for setups like `client → Cloudflare → nginx → next` where Cloudflare is ALSO adding to XFF.
|
|
140
|
+
`X-Forwarded-For` is a list of IPs separated by commas. Each reverse proxy in the chain **appends** the IP of *its own peer*. The *leftmost* entry is whatever the original client claimed — i.e. attacker-controlled. The *rightmost N entries* are what trusted proxies added, so they're authentic. To pick the real client IP safely you must **walk the chain from the right, skipping one entry per trusted proxy**.
|
|
212
141
|
|
|
142
|
+
- `trustProxy: 0` — Never read forwarding headers. Every request hashes to a single constant peer. `uniqueVisitors` will under-count, but **nothing an attacker sends can forge it**.
|
|
143
|
+
- `trustProxy: 1` **(default)** — One trusted reverse proxy in front of this process (`client → nginx → next`). Library takes the **rightmost** entry of `X-Forwarded-For`.
|
|
144
|
+
- `trustProxy: 2` — Two trusted hops (`client → Cloudflare → nginx → next`). Library takes the **second-from-right** entry.
|
|
213
145
|
- `trustProxy: N` — Generalizes to N trusted hops.
|
|
214
146
|
|
|
215
147
|
**nginx recipe (trustProxy = 1):**
|
|
@@ -222,93 +154,64 @@ location / {
|
|
|
222
154
|
}
|
|
223
155
|
```
|
|
224
156
|
|
|
225
|
-
`$proxy_add_x_forwarded_for` appends the client's socket IP to whatever XFF the client sent. With `trustProxy: 1`, statswhatshesaid
|
|
226
|
-
|
|
227
|
-
**Caddy recipe (trustProxy = 1):**
|
|
228
|
-
|
|
229
|
-
```caddyfile
|
|
230
|
-
example.com {
|
|
231
|
-
reverse_proxy 127.0.0.1:3000
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
Caddy automatically appends the client IP to `X-Forwarded-For` by default.
|
|
236
|
-
|
|
237
|
-
**Cloudflare + nginx recipe (trustProxy = 2):**
|
|
238
|
-
|
|
239
|
-
```nginx
|
|
240
|
-
# nginx behind Cloudflare
|
|
241
|
-
location / {
|
|
242
|
-
proxy_pass http://127.0.0.1:3000;
|
|
243
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
With `trustProxy: 2`, the second-from-right entry is the real client: `attacker-spoof, real-client, cloudflare-edge`.
|
|
157
|
+
`$proxy_add_x_forwarded_for` appends the client's socket IP to whatever XFF the client sent. With `trustProxy: 1`, statswhatshesaid takes the rightmost entry (nginx's appended value), and the client's spoofed values sit uselessly to the left.
|
|
248
158
|
|
|
249
|
-
**Direct-exposed (no proxy) warning:** If you're running
|
|
159
|
+
**Direct-exposed (no proxy) warning:** If you're running Next.js straight on `0.0.0.0:3000` with no proxy in front, **any header you see is attacker-controlled**. Set `trustProxy: 0` and accept that visitor dedup won't work, OR put any reverse proxy in front.
|
|
250
160
|
|
|
251
161
|
### 2. Token strength and rate limiting
|
|
252
162
|
|
|
253
|
-
`/stats` is protected by a single static token. A short token is brute-forceable
|
|
163
|
+
`/stats` is protected by a single static token. A short token is brute-forceable.
|
|
254
164
|
|
|
255
|
-
- statswhatshesaid **warns** at startup if your token is shorter than 32 characters. It does not reject — you might
|
|
165
|
+
- statswhatshesaid **warns** at startup if your token is shorter than 32 characters. It does not reject — you might pick a memorable token for ad-hoc browser access.
|
|
256
166
|
- A safer choice: `openssl rand -hex 32` → a 64-char hex string.
|
|
257
|
-
- The library does **not** rate-limit `/stats`. That's your CDN / reverse-proxy / application middleware's job
|
|
167
|
+
- The library does **not** rate-limit `/stats`. That's your CDN / reverse-proxy / application middleware's job ([nginx `limit_req`](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), [Cloudflare rate limiting](https://developers.cloudflare.com/waf/rate-limiting-rules/), [`@upstash/ratelimit`](https://github.com/upstash/ratelimit-js)).
|
|
258
168
|
|
|
259
169
|
### 3. Passing the token: `Authorization` header vs query string
|
|
260
170
|
|
|
261
|
-
|
|
171
|
+
Two ways to pass the token:
|
|
262
172
|
|
|
263
173
|
| Method | Use when |
|
|
264
174
|
| --- | --- |
|
|
265
175
|
| `Authorization: Bearer <token>` header | **Production** — doesn't leak to access logs, browser history, or Referer |
|
|
266
|
-
| `?t=<token>` query string | Ad-hoc browser checks
|
|
176
|
+
| `?t=<token>` query string | Ad-hoc browser checks |
|
|
267
177
|
|
|
268
|
-
Both are accepted. If both are present, the `Authorization` header wins.
|
|
178
|
+
Both are accepted. If both are present, the `Authorization` header wins.
|
|
269
179
|
|
|
270
180
|
```bash
|
|
271
181
|
curl -H "Authorization: Bearer $STATS_TOKEN" https://myapp.com/stats
|
|
272
182
|
```
|
|
273
183
|
|
|
274
|
-
The query string is convenient but ends up in **nginx/CDN access logs, browser history, and Referer headers**. Don't link to `/stats?t=...` from any page.
|
|
275
|
-
|
|
276
184
|
### 4. Count inflation by flooding
|
|
277
185
|
|
|
278
|
-
An attacker who can send arbitrary (IP, User-Agent) pairs
|
|
186
|
+
An attacker who can send arbitrary `(IP, User-Agent)` pairs can insert arbitrarily many distinct "visitors" into the HLL sketch. Memory doesn't blow up (HLL is fixed 16 KB/day), but the reported count becomes meaningless during the attack. The library can't prevent this at the middleware layer — rate-limit at your CDN / reverse proxy.
|
|
279
187
|
|
|
280
|
-
### 5.
|
|
188
|
+
### 5. Privacy properties
|
|
281
189
|
|
|
282
|
-
- The
|
|
283
|
-
-
|
|
284
|
-
-
|
|
190
|
+
- **Cookieless.** The library never sets or reads cookies.
|
|
191
|
+
- **No personal data persisted.** Hashes go into the HLL (which discards them) and are never written anywhere. No filesystem, no remote calls.
|
|
192
|
+
- **Cross-day unlinkability.** The salt rotates at every UTC midnight. Yesterday's hash of `(ip, ua)` is unrelated to today's hash of the same tuple.
|
|
193
|
+
- **No telemetry.** The library makes zero outbound network requests.
|
|
285
194
|
|
|
286
195
|
### 6. User-Agent length cap
|
|
287
196
|
|
|
288
|
-
Incoming User-Agent headers are truncated to **512 bytes** before hashing and bot-filter checks.
|
|
289
|
-
|
|
290
|
-
### 7. Privacy properties
|
|
291
|
-
|
|
292
|
-
- **Cookieless.** The library never sets or reads cookies.
|
|
293
|
-
- **No personal data persisted.** Hashes go into the HLL (which discards them) and are never written to disk.
|
|
294
|
-
- **Cross-day unlinkability.** The salt rotates at every UTC midnight. Yesterday's hash of `(ip, ua)` is unrelated to today's hash of the same tuple.
|
|
295
|
-
- **Mid-day restart caveat.** If the process restarts within the same UTC day, the restored salt (from the snapshot file) is the same, so the same visitor returning after the restart doesn't get double-counted. This means the salt IS on disk for the current day. Rotate `STATS_TOKEN` and delete the snapshot file if you think the file was exposed.
|
|
197
|
+
Incoming User-Agent headers are truncated to **512 bytes** before hashing and bot-filter checks. Bounds per-request CPU regardless of upstream limits.
|
|
296
198
|
|
|
297
199
|
## Where it works
|
|
298
200
|
|
|
299
|
-
- ✅ **Self-hosted Next.js**
|
|
300
|
-
-
|
|
201
|
+
- ✅ **Self-hosted Next.js** (`next start` on a VPS, Docker, Fly.io, Railway, etc.) — single instance.
|
|
202
|
+
- ✅ **Vercel** and other serverless platforms — works in Edge middleware. Counts persist for the lifetime of each isolate; expect them to reset more often than on a long-running self-hosted process.
|
|
203
|
+
- ❌ **Multi-instance deployments** — each replica has its own in-memory counter and they don't sync. The library is single-process by design.
|
|
301
204
|
|
|
302
205
|
## Escape hatch (non-middleware integration)
|
|
303
206
|
|
|
304
|
-
If you
|
|
207
|
+
If you need to call from a route handler or `instrumentation.ts`:
|
|
305
208
|
|
|
306
209
|
```ts
|
|
307
|
-
import
|
|
210
|
+
import { trackRequest } from 'statswhatshesaid'
|
|
308
211
|
import type { NextRequest } from 'next/server'
|
|
309
212
|
|
|
310
|
-
export function GET(req: NextRequest) {
|
|
311
|
-
|
|
213
|
+
export async function GET(req: NextRequest) {
|
|
214
|
+
await trackRequest(req)
|
|
312
215
|
return new Response('ok')
|
|
313
216
|
}
|
|
314
217
|
```
|
|
@@ -328,7 +231,7 @@ The example app under `examples/basic` is the simplest way to smoke-test changes
|
|
|
328
231
|
|
|
329
232
|
## Releasing
|
|
330
233
|
|
|
331
|
-
Versioning and publishing are managed with [Changesets](https://github.com/changesets/changesets) and automated via GitHub Actions.
|
|
234
|
+
Versioning and publishing are managed with [Changesets](https://github.com/changesets/changesets) and automated via the GitHub Actions Release workflow using **npm trusted publishing** (OIDC). No long-lived npm tokens live in the repo.
|
|
332
235
|
|
|
333
236
|
**Day-to-day flow:**
|
|
334
237
|
|
|
@@ -337,26 +240,8 @@ Versioning and publishing are managed with [Changesets](https://github.com/chang
|
|
|
337
240
|
```bash
|
|
338
241
|
npx changeset
|
|
339
242
|
```
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
4. When you merge the release PR, the workflow publishes the new version to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements) attached.
|
|
343
|
-
|
|
344
|
-
**One-time setup:**
|
|
345
|
-
|
|
346
|
-
- The unscoped package name `statswhatshesaid` must be available on npm (`npm view statswhatshesaid` — a 404 means it's yours for the taking on first publish).
|
|
347
|
-
- Add an automation token to the GitHub repo as the `NPM_TOKEN` secret (`Settings → Secrets and variables → Actions`). Use a **granular** token scoped to publish the `statswhatshesaid` package.
|
|
348
|
-
- In `Settings → Actions → General`, under *Workflow permissions*, allow GitHub Actions to **create and approve pull requests** so the release bot can open the version PR.
|
|
349
|
-
|
|
350
|
-
**Manual publishing (escape hatch):**
|
|
351
|
-
|
|
352
|
-
If you ever need to cut a release locally:
|
|
353
|
-
|
|
354
|
-
```bash
|
|
355
|
-
npx changeset version # bumps package.json + updates CHANGELOG
|
|
356
|
-
git commit -am "chore(release): version packages"
|
|
357
|
-
git push
|
|
358
|
-
npm run release # verify + changeset publish
|
|
359
|
-
```
|
|
243
|
+
3. Merge the PR into `main`. The Release workflow opens (or updates) a "chore(release): version packages" PR that bumps `package.json` and updates `CHANGELOG.md`.
|
|
244
|
+
4. When you merge the release PR, the workflow publishes the new version to npm with provenance attached.
|
|
360
245
|
|
|
361
246
|
## License
|
|
362
247
|
|