@davidlee49/launchgate 0.0.0-stage → 0.5.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 +407 -3
- package/dist/chunk-34MIJ7KP.js +99 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +274 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +102 -0
- package/dist/resolver-B2cstRTq.d.ts +161 -0
- package/package.json +47 -3
package/README.md
CHANGED
|
@@ -1,3 +1,407 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# launchgate
|
|
2
|
+
|
|
3
|
+
Hide work in progress from the public while you keep seeing it. Kill a shipped
|
|
4
|
+
feature without a redeploy. No platform, no service, no database required.
|
|
5
|
+
|
|
6
|
+
Design of record: [adr/0001-launch-gate-design.md](adr/0001-launch-gate-design.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { defineFlags, createResolver, envOverride, cookieOverride, envDefault } from "@davidlee49/launchgate";
|
|
10
|
+
|
|
11
|
+
export const flags = defineFlags({
|
|
12
|
+
newPricingPage: {
|
|
13
|
+
fallback: false,
|
|
14
|
+
description: "Rebuilt pricing page — hidden until launch",
|
|
15
|
+
overridable: true,
|
|
16
|
+
byEnv: { development: true },
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const gate = createResolver({
|
|
21
|
+
flags,
|
|
22
|
+
sources: [
|
|
23
|
+
envOverride(), // 1. kill switch
|
|
24
|
+
cookieOverride({ secret: process.env.FLAG_SECRET! }), // 2. you, on the live site
|
|
25
|
+
envDefault({ env: process.env.NODE_ENV! }), // 4. per-environment default
|
|
26
|
+
],
|
|
27
|
+
onError: (error, { flag, source }) => console.error("launchgate", flag, source, error),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (await gate.resolve("newPricingPage", { cookie })) { /* … */ }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How resolution works
|
|
34
|
+
|
|
35
|
+
An **ordered list of sources**; each answers with a value or `undefined`
|
|
36
|
+
(*undecided — ask the next one*); the first definite answer wins. The flag's
|
|
37
|
+
`fallback` is terminal, so it can't be forgotten.
|
|
38
|
+
|
|
39
|
+
| # | Source | Purpose |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| 0 | `retired` | Not a source. A retired flag answers with its recorded disposition and the chain never starts. |
|
|
42
|
+
| 1 | `envOverride()` | `FLAG_NEW_PRICING_PAGE=off`. The kill switch. |
|
|
43
|
+
| 2 | `cookieOverride({ secret })` | Signed per-visitor token — how you see hidden work in production. |
|
|
44
|
+
| 3 | *yours* | Allowlist, grant, opt-in, entitlement. Usually a database read. |
|
|
45
|
+
| 4 | `envDefault({ env })` | The registry's `byEnv` value. Preview deployments are just a different `env`. |
|
|
46
|
+
| — | `fallback` | Terminal, always present. |
|
|
47
|
+
|
|
48
|
+
`false` is a decision. `undefined` is not. That three-valued fall-through is the
|
|
49
|
+
whole mechanic — and there is no traversal: no flag ever consults another flag.
|
|
50
|
+
|
|
51
|
+
### Two invariants
|
|
52
|
+
|
|
53
|
+
- **`envOverride` sits above `cookieOverride`.** A flag forced on by a cookie
|
|
54
|
+
must not survive the attempt to kill it.
|
|
55
|
+
- **`cookieOverride` honours only `overridable: true` flags.** A visitor must
|
|
56
|
+
never force a flag with real consequences.
|
|
57
|
+
|
|
58
|
+
### Failure is per flag, not global
|
|
59
|
+
|
|
60
|
+
Every source call is wrapped. A source that throws **aborts to that flag's
|
|
61
|
+
`fallback`** and reports through `onError` — it does not fall through, so there
|
|
62
|
+
is exactly one degraded answer per flag and it's declared next to the flag.
|
|
63
|
+
|
|
64
|
+
That value is per flag because polarity differs: a WIP gate's safe state is
|
|
65
|
+
`false`, but a kill switch on a year-old feature's safe state is `true` — failing
|
|
66
|
+
*that* one closed would take down working functionality because a database
|
|
67
|
+
blipped.
|
|
68
|
+
|
|
69
|
+
## Retiring a flag
|
|
70
|
+
|
|
71
|
+
A release toggle is temporary by definition, and so is the feature behind it
|
|
72
|
+
eventually. `retired` is the terminal state of both:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
collections: {
|
|
76
|
+
fallback: true,
|
|
77
|
+
description: "Collections — retired 2026-08-29, code awaiting deletion",
|
|
78
|
+
retired: { since: "2026-08-29", value: false },
|
|
79
|
+
},
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It resolves to `value` and **consults no source at all** — not `envOverride`,
|
|
83
|
+
which outranks everything else. That is the point rather than an optimisation: a
|
|
84
|
+
retirement is a decision someone committed to git, so the only way to reverse it
|
|
85
|
+
is to revert the commit. A forgotten `FLAG_COLLECTIONS=on` secret or an override
|
|
86
|
+
cookie an engineer is still holding must not be able to reopen it, or the
|
|
87
|
+
registry stops describing what the system actually does.
|
|
88
|
+
|
|
89
|
+
Note `value` is *not* `fallback`. `fallback` is where a **failure** lands; a
|
|
90
|
+
retirement isn't a failure, and a feature whose safe-on-failure state is `true`
|
|
91
|
+
still retires to `false`. Declaring it separately is also the disposition record
|
|
92
|
+
Unleash makes you supply when completing a flag — the cleanup PR needs to know
|
|
93
|
+
which branch of the code survives, and `value: "v1"` on a dynamic-config flag
|
|
94
|
+
says exactly that.
|
|
95
|
+
|
|
96
|
+
**Keep the entry.** Retirement archives, it doesn't delete — Unleash's rule, for
|
|
97
|
+
Unleash's reason: while the key is in the registry it cannot be reused by a new
|
|
98
|
+
feature (a duplicate key is a type error), so a stale override can never attach
|
|
99
|
+
to something it was never about. Delete the entry *last*, once the components,
|
|
100
|
+
the routes, and the API handlers are gone.
|
|
101
|
+
|
|
102
|
+
### The clock
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const { problems, warnings } = auditRegistry(flags, { today, retiredAfterDays: 40 });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Retired code that nobody deletes is the same debt as a stale release flag, seen
|
|
109
|
+
from the other end, so it gets the same 40-day budget. `auditRegistry` audits
|
|
110
|
+
only what the library owns — the retirement shape and its age. Your own
|
|
111
|
+
lifecycle vocabulary is yours to check; merge the two reports and print them
|
|
112
|
+
together in one build step.
|
|
113
|
+
|
|
114
|
+
This is deliberately a *build* step. LaunchDarkly ships `find-code-refs` to
|
|
115
|
+
crawl a customer's repository, because a SaaS control plane can't see the code
|
|
116
|
+
and can only warn on a dashboard. A registry that lives in the repo doesn't need
|
|
117
|
+
the scanner: it can fail the build instead.
|
|
118
|
+
|
|
119
|
+
## Flags aren't only booleans
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
renderPipeline: {
|
|
123
|
+
fallback: "v1" as "v1" | "v2",
|
|
124
|
+
description: "Which render pipeline a submitted job uses",
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`FLAG_RENDER_PIPELINE=v2` and cookie overrides carry the value. Booleans are the
|
|
129
|
+
common case, not the only one.
|
|
130
|
+
|
|
131
|
+
> **Typing note.** `fallback: false` is widened back to `boolean` for you.
|
|
132
|
+
> Strings and numbers keep what you declare, which is why the `as "v1" | "v2"`
|
|
133
|
+
> above gives you a checked union.
|
|
134
|
+
|
|
135
|
+
## Custom sources
|
|
136
|
+
|
|
137
|
+
A source is `{ name, decide(flag, ctx) }`. Return a value to decide, `undefined`
|
|
138
|
+
to pass:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const orgGrant: Source = {
|
|
142
|
+
name: "orgGrant",
|
|
143
|
+
async decide({ key }, ctx) {
|
|
144
|
+
if (!ctx.targetingKey) return undefined;
|
|
145
|
+
const row = await db.grantFor(ctx.targetingKey, key);
|
|
146
|
+
return row?.enabled; // undefined when there's no row — undecided
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Conjunctions ("entitled **and** opted in") are a *requirement* source: return the
|
|
152
|
+
off-value, or `undefined` to stay out of the way. The chain stays first-wins.
|
|
153
|
+
|
|
154
|
+
## Gating routes
|
|
155
|
+
|
|
156
|
+
Flags answer "is this on". `createRouteGate` answers the question a web app
|
|
157
|
+
actually asks — **"may this path be served"** — from two lists you supply:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const gate = createRouteGate({
|
|
161
|
+
resolver: launch,
|
|
162
|
+
routes: { workflows: ["/workflows"], network: ["/network", "/api/network"] },
|
|
163
|
+
allow: ["/", "/assets", "/api/assets"], // the launched surface
|
|
164
|
+
unlisted: "deny",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await gate.isPermitted("/workflows", { cookie }); // middleware
|
|
168
|
+
await gate.revealedPrefixes({ cookie }); // to filter nav server-side
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The allowlist is checked **first**, so a launched route short-circuits and
|
|
172
|
+
resolves no flags at all.
|
|
173
|
+
|
|
174
|
+
`unlisted` has no default, because it is the whole safety posture and the two
|
|
175
|
+
answers fail very differently:
|
|
176
|
+
|
|
177
|
+
- `"deny"` — a route added tomorrow is hidden until someone lists it. It 404s
|
|
178
|
+
loudly, and safely. Right for an app with unlaunched surfaces in the tree.
|
|
179
|
+
- `"allow"` — a new route ships the moment it exists, and forgetting to flag
|
|
180
|
+
work in progress leaks it silently.
|
|
181
|
+
|
|
182
|
+
A flag that is **off** denies its paths under either posture; `unlisted` only
|
|
183
|
+
decides paths nothing has an opinion about.
|
|
184
|
+
|
|
185
|
+
**It throws at construction** if a prefix is both allowed and flagged. That flag
|
|
186
|
+
could never change anything — the allowlist would serve the path regardless — and
|
|
187
|
+
a dead launch gate is precisely the bug you cannot afford to discover late.
|
|
188
|
+
|
|
189
|
+
`matchesPrefix(pathname, prefixes)` is exported standalone for client-side nav
|
|
190
|
+
filtering, so the menu can't disagree with the router.
|
|
191
|
+
|
|
192
|
+
## Storage-backed flags
|
|
193
|
+
|
|
194
|
+
For projects that want to flip a flag per tenant without a deploy. `subjectStore`
|
|
195
|
+
owns the semantics; you own the SQL — any database, any client:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const store = subjectStore({
|
|
199
|
+
// One read for the whole registry. `load: async (subject, key) => …` is the
|
|
200
|
+
// per-flag alternative when a batch query is awkward.
|
|
201
|
+
loadAll: async (subject) => {
|
|
202
|
+
const rows = await db.overridesFor(subject); // (subject_id, flag_key, override)
|
|
203
|
+
return Object.fromEntries(rows.map((r) => [r.flag_key, { override: r.override, optedIn: r.opted_in }]));
|
|
204
|
+
},
|
|
205
|
+
requiresOptIn: (flag) => flag.def.selfServe === true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
createResolver({
|
|
209
|
+
flags,
|
|
210
|
+
sources: [
|
|
211
|
+
envOverride(),
|
|
212
|
+
store.kill, // 1. a 'disabled' row — outranks the cookie
|
|
213
|
+
cookieOverride({ secret }),
|
|
214
|
+
store.requireOptIn, // 3a. veto anything the tenant hasn't switched on
|
|
215
|
+
store.grant, // 3b. an 'enabled' row
|
|
216
|
+
envDefault({ env }),
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Three sources, one storage read: they share a cache keyed on the `EvaluationContext`
|
|
222
|
+
object, which is per request, so a request pays one `load` per flag no matter how
|
|
223
|
+
many of the three are in the chain. Swap `load` for `loadAll` and the whole
|
|
224
|
+
registry costs one read instead of one per flag — see **Bootstrapping a client** below.
|
|
225
|
+
|
|
226
|
+
`'disabled'` and `'enabled'` are one row but two sources because they belong in
|
|
227
|
+
**different slots** — a per-tenant kill has to outrank a visitor cookie for the
|
|
228
|
+
same reason the env kill switch does, while a grant sits below it. `requireOptIn`
|
|
229
|
+
sits ahead of `grant` because it must be able to veto an entitlement decided by a
|
|
230
|
+
*later* source (a plan lookup, a stage rule) — a veto that runs after the
|
|
231
|
+
decision it's vetoing never fires.
|
|
232
|
+
|
|
233
|
+
Project-specific policy stays yours. Hang your own fields on a flag def (`stage`,
|
|
234
|
+
`tier`, `owner`) and read them in your own source — the registry keeps their real
|
|
235
|
+
types.
|
|
236
|
+
|
|
237
|
+
> There is no `launchgate/postgres` subpath. Once the interface is a `load`
|
|
238
|
+
> function, nothing Postgres-specific is left to ship.
|
|
239
|
+
|
|
240
|
+
## Bootstrapping a client
|
|
241
|
+
|
|
242
|
+
`resolveAll(ctx)` evaluates the whole registry at once — LaunchDarkly's
|
|
243
|
+
`allFlagsState()` pattern. Resolve on the server, hand the map down, and the UI
|
|
244
|
+
never flickers because it never had to ask:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const enabled = await gate.resolveAll(await requestContext({ targetingKey: orgId }));
|
|
248
|
+
// → { newPricingPage: true, network: false, renderPipeline: "v2" }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Every flag shares the one context object, which is the cache key
|
|
252
|
+
`subjectStore` uses — so with `loadAll` the whole registry costs **one** read.
|
|
253
|
+
|
|
254
|
+
> Pass the **values**, never the registry. Flag keys name unshipped work, and a
|
|
255
|
+
> viewer who can't see a surface shouldn't learn its name from the payload.
|
|
256
|
+
|
|
257
|
+
## Testing
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { withFlags } from "@davidlee49/launchgate";
|
|
261
|
+
|
|
262
|
+
const gate = withFlags(flags, { newPricingPage: true });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Answers from a fixed map, falls back for anything unset. Use it instead of
|
|
266
|
+
standing up real sources — flags multiply a test matrix, and that's only
|
|
267
|
+
tolerable if pinning a value is one line.
|
|
268
|
+
|
|
269
|
+
## Minting an override token
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { signOverrides } from "@davidlee49/launchgate";
|
|
273
|
+
|
|
274
|
+
const token = await signOverrides({ newPricingPage: true }, secret);
|
|
275
|
+
// set it as the `lg_override` cookie
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
HMAC-SHA256 over Web Crypto, so the same build runs on Cloudflare Workers and
|
|
279
|
+
Node 18+. Tampered or foreign-signed tokens are silently *undecided* — hostile
|
|
280
|
+
input falls through the normal chain rather than pushing the flag anywhere.
|
|
281
|
+
|
|
282
|
+
## What this deliberately isn't
|
|
283
|
+
|
|
284
|
+
- **Not experimentation.** No assignment, no exposure logging, no metrics. At the
|
|
285
|
+
scale this is built for, statistical inference is noise with a dashboard.
|
|
286
|
+
- **Not entitlements.** Those derive from a *contract*. Put billing truth in a
|
|
287
|
+
flag store and it has no transactional relationship to the subscription, so a
|
|
288
|
+
downgrade and a flag flip can disagree — and a stale-flag sweep deletes revenue
|
|
289
|
+
logic that looked like dead state. Supply an entitlement source instead.
|
|
290
|
+
- **Not a circuit breaker.** Failing over to a backup provider is a machine
|
|
291
|
+
decision made in seconds from observed health; as a flag, a human has to notice
|
|
292
|
+
the outage first. A flag may say whether the alternative path is *permitted to
|
|
293
|
+
exist* — it must never *choose* it.
|
|
294
|
+
|
|
295
|
+
> A flag records a decision a person made. A circuit breaker records a fact the
|
|
296
|
+
> system observed.
|
|
297
|
+
|
|
298
|
+
## Next.js
|
|
299
|
+
|
|
300
|
+
```sh
|
|
301
|
+
pnpm add @davidlee49/launchgate # `next` is an optional peer dependency
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
// app/flag-override/route.ts ← NOT app/__flags/: see the trap below
|
|
306
|
+
import { createOverrideRoute } from "@davidlee49/launchgate/next";
|
|
307
|
+
import { gate } from "@/flags";
|
|
308
|
+
|
|
309
|
+
export const { GET } = createOverrideRoute({
|
|
310
|
+
resolver: gate,
|
|
311
|
+
secret: process.env.FLAG_SECRET!,
|
|
312
|
+
accessToken: process.env.FLAG_ACCESS_TOKEN!, // presented as ?token=, never the signing secret
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**If your app has an auth gate, exclude this route from it.** Inside the matcher
|
|
317
|
+
it redirects to your login page and the cookie is never set — the mechanic
|
|
318
|
+
silently does nothing. Its `?token=` is its authorization, and working logged-out
|
|
319
|
+
is the point.
|
|
320
|
+
|
|
321
|
+
Then `GET /flag-override?token=…&flag=newHomepage&value=on` sets the cookie,
|
|
322
|
+
`&value=off` flips it, omitting `value` clears that flag, and `&clear=1` drops
|
|
323
|
+
all of them. A wrong token gets a 404, not a 401 — an unadvertised route should
|
|
324
|
+
stay unadvertised.
|
|
325
|
+
|
|
326
|
+
Read a flag in a page or handler:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { requestContext, requireFlag } from "@davidlee49/launchgate/next";
|
|
330
|
+
|
|
331
|
+
const on = await gate.resolve("newHomepage", await requestContext()); // page
|
|
332
|
+
const blocked = await requireFlag(gate, "network", { targetingKey: orgId }); // handler → 404
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Static rendering — measured, not guessed
|
|
336
|
+
|
|
337
|
+
`requestContext()` calls `cookies()`, and that **opts the page into dynamic
|
|
338
|
+
rendering**. Verified against Next 16.3.1 by reading `next build`'s route table:
|
|
339
|
+
|
|
340
|
+
| Page | How it reads the flag | Build classification |
|
|
341
|
+
| --- | --- | --- |
|
|
342
|
+
| `/hidden` | `requestContext()` in the page | **ƒ Dynamic** |
|
|
343
|
+
| `/home` → `/variants/v1`,`/v2` | proxy reads the cookie and rewrites | **○ Static** (all three) |
|
|
344
|
+
|
|
345
|
+
- **Already-dynamic page** (anything authenticated): just call
|
|
346
|
+
`requestContext()`. The dynamic cost is already paid.
|
|
347
|
+
- **Page that must stay static** (marketing, landing, docs): don't read the flag
|
|
348
|
+
in the page. Author the variants as separate routes and rewrite in `proxy.ts`:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
// proxy.ts (Next 16's rename of middleware.ts)
|
|
352
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
353
|
+
import { readOverrides } from "@davidlee49/launchgate";
|
|
354
|
+
|
|
355
|
+
export async function proxy(request: NextRequest) {
|
|
356
|
+
const token = request.cookies.get("lg_override")?.value;
|
|
357
|
+
const overrides = token ? await readOverrides(token, process.env.FLAG_SECRET!) : undefined;
|
|
358
|
+
const variant = overrides?.newHomepage === true ? "v2" : "v1";
|
|
359
|
+
return NextResponse.rewrite(new URL(`/variants/${variant}`, request.url));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const config = { matcher: "/home" };
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
There is no third option. Resolving in the proxy and forwarding a request header
|
|
366
|
+
doesn't help: the page then calls `headers()`, which opts it into dynamic
|
|
367
|
+
rendering just the same. Forwarding moves the read, not the cost.
|
|
368
|
+
|
|
369
|
+
> **Trap.** A route handler under a `_`-prefixed directory — `app/__flags/route.ts`
|
|
370
|
+
> — **never registers**. `_` marks a private folder in the App Router, excluded
|
|
371
|
+
> from routing silently, with no build error and no 404 in the route table. Mount
|
|
372
|
+
> the override route at a normal path.
|
|
373
|
+
|
|
374
|
+
## Async work
|
|
375
|
+
|
|
376
|
+
Resolve at submission and **stamp the value into the job payload**. A job
|
|
377
|
+
enqueued Monday and run Wednesday must use Monday's value, or you get a job
|
|
378
|
+
that's half one behaviour and half the other — and under a deterministic workflow
|
|
379
|
+
engine, a replay failure. Downstream services get resolved values, not a flag
|
|
380
|
+
client. Records produced by gated code should carry their own provenance rather
|
|
381
|
+
than being re-interpreted through the current flag value.
|
|
382
|
+
|
|
383
|
+
## Install
|
|
384
|
+
|
|
385
|
+
```sh
|
|
386
|
+
pnpm add @davidlee49/launchgate
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Published to npm as a public package (scoped: the bare name `launchgate` is
|
|
390
|
+
taken on the registry). The repo is private; the tarball is the built `dist/`.
|
|
391
|
+
|
|
392
|
+
## Releasing
|
|
393
|
+
|
|
394
|
+
1. Bump `version` in `package.json`.
|
|
395
|
+
2. `pnpm publish` — `prepublishOnly` runs `pnpm check` (typecheck, tests, build),
|
|
396
|
+
so an unbuilt or failing tree never ships. `dist/` is not committed; the
|
|
397
|
+
registry holds it.
|
|
398
|
+
3. `git tag vX.Y.Z && git push origin main vX.Y.Z`.
|
|
399
|
+
4. Consumers bump the version range and `pnpm install`. A published version is
|
|
400
|
+
immutable: a fix is a new version, never a re-tag.
|
|
401
|
+
|
|
402
|
+
## Development
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
pnpm install
|
|
406
|
+
pnpm check # tsc --noEmit && vitest run
|
|
407
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/signing.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
var decoder = new TextDecoder();
|
|
4
|
+
function b64urlFromBytes(bytes) {
|
|
5
|
+
let binary = "";
|
|
6
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
7
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
function bytesFromB64url(value) {
|
|
10
|
+
const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
11
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
12
|
+
const binary = atob(padded);
|
|
13
|
+
const bytes = new Uint8Array(binary.length);
|
|
14
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
15
|
+
return bytes;
|
|
16
|
+
}
|
|
17
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
18
|
+
function hmacKey(secret) {
|
|
19
|
+
let key = keyCache.get(secret);
|
|
20
|
+
if (!key) {
|
|
21
|
+
key = crypto.subtle.importKey(
|
|
22
|
+
"raw",
|
|
23
|
+
encoder.encode(secret),
|
|
24
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
25
|
+
false,
|
|
26
|
+
["sign"]
|
|
27
|
+
);
|
|
28
|
+
keyCache.set(secret, key);
|
|
29
|
+
}
|
|
30
|
+
return key;
|
|
31
|
+
}
|
|
32
|
+
async function signature(payload, secret) {
|
|
33
|
+
const key = await hmacKey(secret);
|
|
34
|
+
const signed = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
35
|
+
return b64urlFromBytes(new Uint8Array(signed));
|
|
36
|
+
}
|
|
37
|
+
function timingSafeEqual(a, b) {
|
|
38
|
+
if (a.length !== b.length) return false;
|
|
39
|
+
let diff = 0;
|
|
40
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
41
|
+
return diff === 0;
|
|
42
|
+
}
|
|
43
|
+
function isFlagValue(value) {
|
|
44
|
+
return typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
45
|
+
}
|
|
46
|
+
async function signOverrides(overrides, secret) {
|
|
47
|
+
const payload = b64urlFromBytes(encoder.encode(JSON.stringify(overrides)));
|
|
48
|
+
return `${payload}.${await signature(payload, secret)}`;
|
|
49
|
+
}
|
|
50
|
+
async function readOverrides(token, secret) {
|
|
51
|
+
const split = token.lastIndexOf(".");
|
|
52
|
+
if (split < 1) return void 0;
|
|
53
|
+
const payload = token.slice(0, split);
|
|
54
|
+
const provided = token.slice(split + 1);
|
|
55
|
+
let expected;
|
|
56
|
+
try {
|
|
57
|
+
expected = await signature(payload, secret);
|
|
58
|
+
} catch {
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
if (!timingSafeEqual(provided, expected)) return void 0;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(decoder.decode(bytesFromB64url(payload)));
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
const overrides = {};
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (isFlagValue(value)) overrides[key] = value;
|
|
70
|
+
}
|
|
71
|
+
return overrides;
|
|
72
|
+
} catch {
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/sources/cookie.ts
|
|
78
|
+
var DEFAULT_OVERRIDE_COOKIE = "lg_override";
|
|
79
|
+
function cookieOverride(options) {
|
|
80
|
+
const { secret } = options;
|
|
81
|
+
const cookieName = options.cookieName ?? DEFAULT_OVERRIDE_COOKIE;
|
|
82
|
+
return {
|
|
83
|
+
name: "cookieOverride",
|
|
84
|
+
async decide({ key, def }, ctx) {
|
|
85
|
+
if (!def.overridable) return void 0;
|
|
86
|
+
const token = ctx.cookie?.(cookieName);
|
|
87
|
+
if (!token) return void 0;
|
|
88
|
+
const overrides = await readOverrides(token, secret);
|
|
89
|
+
return overrides?.[key];
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
signOverrides,
|
|
96
|
+
readOverrides,
|
|
97
|
+
DEFAULT_OVERRIDE_COOKIE,
|
|
98
|
+
cookieOverride
|
|
99
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { S as Source, F as FlagValue, a as FlagMeta, E as EvaluationContext, b as FlagRegistry, R as Resolver, c as FlagValueOf } from './resolver-B2cstRTq.js';
|
|
2
|
+
export { C as Context, D as Decision, d as FlagDef, e as ResolvedFlags, f as ResolverOptions, g as Retirement, h as SourceErrorInfo, i as createResolver, j as defineFlags } from './resolver-B2cstRTq.js';
|
|
3
|
+
|
|
4
|
+
interface EnvOverrideOptions {
|
|
5
|
+
/** How to read an environment variable. Defaults to `process.env` where it exists. */
|
|
6
|
+
read?: (name: string) => string | undefined;
|
|
7
|
+
/** Variable-name prefix. Default `FLAG_`. */
|
|
8
|
+
prefix?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Slot 1 — the operator override, whose primary use is the kill switch.
|
|
12
|
+
*
|
|
13
|
+
* `FLAG_NEW_HOMEPAGE=off` forces a flag off; any value the flag's type accepts
|
|
14
|
+
* forces that value. This sits **above** `cookieOverride` deliberately: a flag
|
|
15
|
+
* forced on by a visitor cookie must not survive the attempt to kill it.
|
|
16
|
+
*
|
|
17
|
+
* An unparseable value throws, which aborts to the flag's fallback and reports
|
|
18
|
+
* through `onError` — an operator typo is loud rather than silently ignored.
|
|
19
|
+
*/
|
|
20
|
+
declare function envOverride(options?: EnvOverrideOptions): Source;
|
|
21
|
+
interface EnvDefaultOptions {
|
|
22
|
+
/** The current environment name, matched against each flag's `byEnv`. */
|
|
23
|
+
env: string | (() => string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Slot 4 — the registry's per-environment value, e.g. `byEnv: { development: true }`.
|
|
27
|
+
*
|
|
28
|
+
* This is also the whole of "preview deployments": a preview host is just a
|
|
29
|
+
* different `env` with different values, needing no runtime machinery.
|
|
30
|
+
*/
|
|
31
|
+
declare function envDefault(options: EnvDefaultOptions): Source;
|
|
32
|
+
|
|
33
|
+
declare const DEFAULT_OVERRIDE_COOKIE = "lg_override";
|
|
34
|
+
interface CookieOverrideOptions {
|
|
35
|
+
/** Secret for the HMAC. Must not be readable by the client. */
|
|
36
|
+
secret: string;
|
|
37
|
+
/** Cookie holding the signed token. Default `lg_override`. */
|
|
38
|
+
cookieName?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Slot 2 — the signed per-visitor override: how the author sees hidden work on
|
|
42
|
+
* the real production site, with no account and no database.
|
|
43
|
+
*
|
|
44
|
+
* Honours **only** flags declaring `overridable: true`. A visitor must never be
|
|
45
|
+
* able to force a flag with real consequences — harmless while flags are
|
|
46
|
+
* cosmetic, load-bearing the day a project puts an entitlement source in slot 3.
|
|
47
|
+
*
|
|
48
|
+
* A missing, malformed, or tampered token is *undecided*, not an error: hostile
|
|
49
|
+
* input should fall through to the normal chain, not push the flag to its
|
|
50
|
+
* fallback.
|
|
51
|
+
*/
|
|
52
|
+
declare function cookieOverride(options: CookieOverrideOptions): Source;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What storage knows about one subject and one flag.
|
|
56
|
+
*
|
|
57
|
+
* `override` is written only by the platform side. `'disabled'` is the per-subject
|
|
58
|
+
* kill switch, `'enabled'` the grant (beta cohort, comp, early access) — and they
|
|
59
|
+
* sit in *different* slots of the chain, which is why they are one row but two
|
|
60
|
+
* sources.
|
|
61
|
+
*/
|
|
62
|
+
interface SubjectState {
|
|
63
|
+
override?: "enabled" | "disabled";
|
|
64
|
+
/** Grant a specific value rather than `true`. For value-typed flags. */
|
|
65
|
+
value?: FlagValue;
|
|
66
|
+
/** Has the subject switched it on themselves? */
|
|
67
|
+
optedIn?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Projects may return their own fields alongside these and read them back via
|
|
70
|
+
* `store.state()` — impartire's loader returns the org's `is_platform` here so
|
|
71
|
+
* its stage policy shares this read instead of issuing a second query.
|
|
72
|
+
*/
|
|
73
|
+
[projectField: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
type LoadSubjectState = (subject: string, key: string, flag: FlagMeta) => Promise<SubjectState | undefined> | SubjectState | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Every flag's state for one subject, in one read. Keys absent from the result
|
|
78
|
+
* are treated as "no row", exactly as `load` returning `undefined` is.
|
|
79
|
+
*/
|
|
80
|
+
type LoadAllSubjectState = (subject: string) => Promise<Map<string, SubjectState> | Record<string, SubjectState>> | Map<string, SubjectState> | Record<string, SubjectState>;
|
|
81
|
+
interface SubjectStoreOptions {
|
|
82
|
+
/** Per-flag read. Supply this, `loadAll`, or both. */
|
|
83
|
+
load?: LoadSubjectState;
|
|
84
|
+
/**
|
|
85
|
+
* Batch read for the whole registry. When present it is preferred, and one
|
|
86
|
+
* subject costs **one** read per request however many flags resolve — which
|
|
87
|
+
* is what makes `resolver.resolveAll()` affordable. Without it, evaluating
|
|
88
|
+
* N flags means N reads.
|
|
89
|
+
*/
|
|
90
|
+
loadAll?: LoadAllSubjectState;
|
|
91
|
+
/**
|
|
92
|
+
* Flags the subject must switch on for themselves — entitlement alone isn't
|
|
93
|
+
* enough. Default: none.
|
|
94
|
+
*/
|
|
95
|
+
requiresOptIn?: (flag: FlagMeta) => boolean;
|
|
96
|
+
}
|
|
97
|
+
interface SubjectStore {
|
|
98
|
+
/** Slot 1, beside `envOverride`. A per-subject kill outranks a visitor cookie. */
|
|
99
|
+
kill: Source;
|
|
100
|
+
/** Slot 3a. Vetoes a flag the subject hasn't switched on. */
|
|
101
|
+
requireOptIn: Source;
|
|
102
|
+
/** Slot 3b. The platform's grant. */
|
|
103
|
+
grant: Source;
|
|
104
|
+
/**
|
|
105
|
+
* The cached read, for a project's own policy source. Sharing it is what keeps
|
|
106
|
+
* a bespoke rule from costing an extra query.
|
|
107
|
+
*/
|
|
108
|
+
state(flag: FlagMeta, ctx: EvaluationContext): Promise<SubjectState | undefined>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Three sources over one storage read.
|
|
112
|
+
*
|
|
113
|
+
* They share a cache keyed on the `EvaluationContext` object, which is per request, so a
|
|
114
|
+
* request pays **one** load per flag however many of the three are in the chain —
|
|
115
|
+
* the alternative being the same row fetched three times. Supply `loadAll` and
|
|
116
|
+
* the whole registry collapses to one read as well, which is what makes
|
|
117
|
+
* `resolver.resolveAll()` affordable.
|
|
118
|
+
*
|
|
119
|
+
* The library owns the semantics; you own the SQL. `load` returns whatever your
|
|
120
|
+
* schema says; a row shape of `(subject_id, flag_key, override)` is all this needs.
|
|
121
|
+
*/
|
|
122
|
+
declare function subjectStore(options: SubjectStoreOptions): SubjectStore;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Registry hygiene, checked at build time rather than watched on a dashboard.
|
|
126
|
+
*
|
|
127
|
+
* LaunchDarkly ships `find-code-refs` — a scanner that crawls a customer's repo
|
|
128
|
+
* so the SaaS control plane can warn that an archived key still appears in code.
|
|
129
|
+
* That tool exists to buy back something a code-first registry has for free: the
|
|
130
|
+
* flags *are* source, so their hygiene is a build step, and a build step can
|
|
131
|
+
* fail rather than warn. This is that build step.
|
|
132
|
+
*
|
|
133
|
+
* It audits only what the library owns — the retirement clock and the shape of
|
|
134
|
+
* a `Retirement`. A project's own lifecycle vocabulary (impartire's `stage`, a
|
|
135
|
+
* `since` stamp on unreleased work, an owner field) is audited by the project,
|
|
136
|
+
* which knows what those mean; merge the two reports and print them together.
|
|
137
|
+
*/
|
|
138
|
+
interface AuditOptions {
|
|
139
|
+
/** Defaults to now. Injectable so the age checks are testable. */
|
|
140
|
+
today?: Date;
|
|
141
|
+
/**
|
|
142
|
+
* How long retired code may sit undeleted before the report warns.
|
|
143
|
+
*
|
|
144
|
+
* 40 days by default, matching Unleash's documented expected lifetime for a
|
|
145
|
+
* release flag: retirement is the same debt seen from the other end, and a
|
|
146
|
+
* budget nobody picked is a budget nobody keeps.
|
|
147
|
+
*/
|
|
148
|
+
retiredAfterDays?: number;
|
|
149
|
+
}
|
|
150
|
+
interface AuditReport {
|
|
151
|
+
/** Registry defects. A consumer should fail the build on these. */
|
|
152
|
+
problems: string[];
|
|
153
|
+
/** Debt worth seeing every run, but not worth blocking a merge. */
|
|
154
|
+
warnings: string[];
|
|
155
|
+
}
|
|
156
|
+
declare function auditRegistry(flags: FlagRegistry, options?: AuditOptions): AuditReport;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Prefix match: exact, or a path segment below it.
|
|
160
|
+
*
|
|
161
|
+
* `"/"` matches only `"/"` — it falls out of the general rule rather than needing
|
|
162
|
+
* a special case, because no real pathname starts with `"//"`.
|
|
163
|
+
*
|
|
164
|
+
* Exported standalone because nav filtering usually runs in a client component,
|
|
165
|
+
* where the rule must be identical to the server's or the menu and the router
|
|
166
|
+
* disagree.
|
|
167
|
+
*/
|
|
168
|
+
declare function matchesPrefix(pathname: string, prefixes: readonly string[]): boolean;
|
|
169
|
+
interface RouteGateOptions<T extends FlagRegistry> {
|
|
170
|
+
resolver: Resolver<T>;
|
|
171
|
+
/** Flag key → the path prefixes it reveals. A flag may reveal several. */
|
|
172
|
+
routes: Partial<Record<keyof T & string, readonly string[]>>;
|
|
173
|
+
/**
|
|
174
|
+
* Paths served regardless of any flag — the launched surface. Omit for an
|
|
175
|
+
* app whose routes are all either public or flagged.
|
|
176
|
+
*/
|
|
177
|
+
allow?: readonly string[];
|
|
178
|
+
/**
|
|
179
|
+
* What to do with a path that is neither allowed nor covered by a flag.
|
|
180
|
+
*
|
|
181
|
+
* Required, with no default, because it is the whole safety posture and the
|
|
182
|
+
* two answers fail in very different ways. `"deny"` means a route added
|
|
183
|
+
* tomorrow is hidden until someone lists it — it 404s loudly and safely.
|
|
184
|
+
* `"allow"` means it ships publicly the moment it exists, and forgetting to
|
|
185
|
+
* flag work in progress leaks it silently.
|
|
186
|
+
*/
|
|
187
|
+
unlisted: "deny" | "allow";
|
|
188
|
+
}
|
|
189
|
+
interface RouteGate {
|
|
190
|
+
/** May this path be served right now? */
|
|
191
|
+
isPermitted(pathname: string, ctx?: EvaluationContext): Promise<boolean>;
|
|
192
|
+
/** Prefixes currently revealed by a flag. For filtering nav server-side. */
|
|
193
|
+
revealedPrefixes(ctx?: EvaluationContext): Promise<string[]>;
|
|
194
|
+
/** Everything servable right now — `allow` plus whatever is revealed. */
|
|
195
|
+
permittedPrefixes(ctx?: EvaluationContext): Promise<string[]>;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Turns "which flags are on" into "which paths may be served".
|
|
199
|
+
*
|
|
200
|
+
* The app supplies two lists — what is launched, and which flag hides what. This
|
|
201
|
+
* owns the policy: check the allowlist first (so a launched route costs no flag
|
|
202
|
+
* resolution at all), then ask the flags.
|
|
203
|
+
*
|
|
204
|
+
* **Throws at construction** if a prefix is both allowed and flagged. That flag
|
|
205
|
+
* could never change anything, because the allowlist would already be letting
|
|
206
|
+
* the path through — a silent no-op that is very hard to spot by reading, and
|
|
207
|
+
* exactly the kind of thing a launch gate must not have.
|
|
208
|
+
*/
|
|
209
|
+
declare function createRouteGate<T extends FlagRegistry>(options: RouteGateOptions<T>): RouteGate;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Mints the signed token that `cookieOverride` reads. Lives in the core rather
|
|
213
|
+
* than the framework adapter so an adapter is only cookie plumbing.
|
|
214
|
+
*/
|
|
215
|
+
declare function signOverrides(overrides: Record<string, FlagValue>, secret: string): Promise<string>;
|
|
216
|
+
/** Verifies and parses a token. Returns `undefined` for anything not genuinely signed. */
|
|
217
|
+
declare function readOverrides(token: string, secret: string): Promise<Record<string, FlagValue> | undefined>;
|
|
218
|
+
|
|
219
|
+
type FlagValues<T extends FlagRegistry> = Partial<{
|
|
220
|
+
[K in keyof T]: FlagValueOf<T, K>;
|
|
221
|
+
}>;
|
|
222
|
+
/**
|
|
223
|
+
* A resolver that answers from a fixed map, falling back to each flag's declared
|
|
224
|
+
* `fallback` for keys left unset.
|
|
225
|
+
*
|
|
226
|
+
* Use this in consumers' tests instead of standing up real sources: flags
|
|
227
|
+
* multiply a test matrix, and the multiplication is only tolerable if pinning a
|
|
228
|
+
* value is one line.
|
|
229
|
+
*/
|
|
230
|
+
declare function withFlags<T extends FlagRegistry>(flags: T, values?: FlagValues<T>): Resolver<T>;
|
|
231
|
+
|
|
232
|
+
export { type AuditOptions, type AuditReport, type CookieOverrideOptions, DEFAULT_OVERRIDE_COOKIE, type EnvDefaultOptions, type EnvOverrideOptions, EvaluationContext, FlagMeta, FlagRegistry, FlagValue, type FlagValues, type LoadAllSubjectState, type LoadSubjectState, Resolver, type RouteGate, type RouteGateOptions, Source, type SubjectState, type SubjectStore, type SubjectStoreOptions, auditRegistry, cookieOverride, createRouteGate, envDefault, envOverride, matchesPrefix, readOverrides, signOverrides, subjectStore, withFlags };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_OVERRIDE_COOKIE,
|
|
3
|
+
cookieOverride,
|
|
4
|
+
readOverrides,
|
|
5
|
+
signOverrides
|
|
6
|
+
} from "./chunk-34MIJ7KP.js";
|
|
7
|
+
|
|
8
|
+
// src/resolver.ts
|
|
9
|
+
function defineFlags(flags) {
|
|
10
|
+
return flags;
|
|
11
|
+
}
|
|
12
|
+
function createResolver(options) {
|
|
13
|
+
const { flags, sources, onError } = options;
|
|
14
|
+
async function resolve(key, ctx = {}) {
|
|
15
|
+
const def = flags[key];
|
|
16
|
+
if (!def) throw new Error(`launchgate: unknown flag "${key}"`);
|
|
17
|
+
if (def.retired) return def.retired.value;
|
|
18
|
+
const meta = { key, def };
|
|
19
|
+
for (const source of sources) {
|
|
20
|
+
let decision;
|
|
21
|
+
try {
|
|
22
|
+
decision = await source.decide(meta, ctx);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
onError?.(error, { flag: key, source: source.name });
|
|
25
|
+
return def.fallback;
|
|
26
|
+
}
|
|
27
|
+
if (decision === void 0) continue;
|
|
28
|
+
if (typeof decision !== typeof def.fallback) {
|
|
29
|
+
onError?.(
|
|
30
|
+
new TypeError(
|
|
31
|
+
`launchgate: source "${source.name}" answered ${typeof decision} for flag "${key}", which is ${typeof def.fallback}`
|
|
32
|
+
),
|
|
33
|
+
{ flag: key, source: source.name }
|
|
34
|
+
);
|
|
35
|
+
return def.fallback;
|
|
36
|
+
}
|
|
37
|
+
return decision;
|
|
38
|
+
}
|
|
39
|
+
return def.fallback;
|
|
40
|
+
}
|
|
41
|
+
async function resolveAll(ctx = {}) {
|
|
42
|
+
const keys = Object.keys(flags);
|
|
43
|
+
const values = await Promise.all(keys.map((key) => resolve(key, ctx)));
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
keys.map((key, i) => [key, values[i]])
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return { flags, resolve, resolveAll };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/sources/env.ts
|
|
52
|
+
function envName(key) {
|
|
53
|
+
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
|
|
54
|
+
}
|
|
55
|
+
function parseAs(raw, fallback, variable) {
|
|
56
|
+
const trimmed = raw.trim();
|
|
57
|
+
if (typeof fallback === "boolean") {
|
|
58
|
+
const v = trimmed.toLowerCase();
|
|
59
|
+
if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
|
|
60
|
+
if (v === "off" || v === "false" || v === "0" || v === "no") return false;
|
|
61
|
+
throw new Error(
|
|
62
|
+
`launchgate: ${variable}="${raw}" is not on/off for a boolean flag`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (typeof fallback === "number") {
|
|
66
|
+
const n = Number(trimmed);
|
|
67
|
+
if (!Number.isFinite(n)) {
|
|
68
|
+
throw new Error(`launchgate: ${variable}="${raw}" is not a number`);
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
return trimmed;
|
|
73
|
+
}
|
|
74
|
+
function defaultRead(name) {
|
|
75
|
+
const proc = globalThis.process;
|
|
76
|
+
return proc?.env?.[name];
|
|
77
|
+
}
|
|
78
|
+
function envOverride(options = {}) {
|
|
79
|
+
const read = options.read ?? defaultRead;
|
|
80
|
+
const prefix = options.prefix ?? "FLAG_";
|
|
81
|
+
return {
|
|
82
|
+
name: "envOverride",
|
|
83
|
+
decide({ key, def }) {
|
|
84
|
+
const variable = prefix + envName(key);
|
|
85
|
+
const raw = read(variable);
|
|
86
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
87
|
+
return parseAs(raw, def.fallback, variable);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function envDefault(options) {
|
|
92
|
+
const { env } = options;
|
|
93
|
+
return {
|
|
94
|
+
name: "envDefault",
|
|
95
|
+
decide({ def }) {
|
|
96
|
+
const current = typeof env === "function" ? env() : env;
|
|
97
|
+
return def.byEnv?.[current];
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/sources/subject.ts
|
|
103
|
+
function offValue(flag) {
|
|
104
|
+
return typeof flag.def.fallback === "boolean" ? false : flag.def.fallback;
|
|
105
|
+
}
|
|
106
|
+
function subjectStore(options) {
|
|
107
|
+
const { load, loadAll, requiresOptIn } = options;
|
|
108
|
+
if (!load && !loadAll) {
|
|
109
|
+
throw new Error("launchgate: subjectStore needs `load` or `loadAll`");
|
|
110
|
+
}
|
|
111
|
+
const caches = /* @__PURE__ */ new WeakMap();
|
|
112
|
+
function read(flag, ctx, subject) {
|
|
113
|
+
let cache = caches.get(ctx);
|
|
114
|
+
if (!cache) {
|
|
115
|
+
cache = { perFlag: /* @__PURE__ */ new Map(), batch: /* @__PURE__ */ new Map() };
|
|
116
|
+
caches.set(ctx, cache);
|
|
117
|
+
}
|
|
118
|
+
if (loadAll) {
|
|
119
|
+
let batched = cache.batch.get(subject);
|
|
120
|
+
if (!batched) {
|
|
121
|
+
batched = Promise.resolve(loadAll(subject)).then(
|
|
122
|
+
(result) => result instanceof Map ? result : new Map(Object.entries(result))
|
|
123
|
+
);
|
|
124
|
+
cache.batch.set(subject, batched);
|
|
125
|
+
}
|
|
126
|
+
return batched.then((states) => states.get(flag.key));
|
|
127
|
+
}
|
|
128
|
+
const cacheKey = `${subject}\0${flag.key}`;
|
|
129
|
+
let pending = cache.perFlag.get(cacheKey);
|
|
130
|
+
if (!pending) {
|
|
131
|
+
pending = Promise.resolve(load(subject, flag.key, flag));
|
|
132
|
+
cache.perFlag.set(cacheKey, pending);
|
|
133
|
+
}
|
|
134
|
+
return pending;
|
|
135
|
+
}
|
|
136
|
+
function source(name, decide, applies = () => true) {
|
|
137
|
+
return {
|
|
138
|
+
name,
|
|
139
|
+
async decide(flag, ctx) {
|
|
140
|
+
if (!ctx.targetingKey || !applies(flag)) return void 0;
|
|
141
|
+
const state = await read(flag, ctx, ctx.targetingKey);
|
|
142
|
+
return state ? decide(state, flag) : decide({}, flag);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
state: async (flag, ctx) => ctx.targetingKey ? read(flag, ctx, ctx.targetingKey) : void 0,
|
|
148
|
+
kill: source(
|
|
149
|
+
"subjectKill",
|
|
150
|
+
(state, flag) => state.override === "disabled" ? offValue(flag) : void 0
|
|
151
|
+
),
|
|
152
|
+
requireOptIn: source(
|
|
153
|
+
"subjectOptIn",
|
|
154
|
+
(state, flag) => state.optedIn ? void 0 : offValue(flag),
|
|
155
|
+
(flag) => requiresOptIn?.(flag) === true
|
|
156
|
+
),
|
|
157
|
+
grant: source("subjectGrant", (state, flag) => {
|
|
158
|
+
if (state.override !== "enabled") return void 0;
|
|
159
|
+
if (state.value !== void 0) return state.value;
|
|
160
|
+
return typeof flag.def.fallback === "boolean" ? true : void 0;
|
|
161
|
+
})
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/audit.ts
|
|
166
|
+
var DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
167
|
+
function auditRegistry(flags, options = {}) {
|
|
168
|
+
const { today = /* @__PURE__ */ new Date(), retiredAfterDays = 40 } = options;
|
|
169
|
+
const problems = [];
|
|
170
|
+
const warnings = [];
|
|
171
|
+
for (const [key, def] of Object.entries(flags)) {
|
|
172
|
+
const retired = def.retired;
|
|
173
|
+
if (!retired) continue;
|
|
174
|
+
if (typeof retired.value !== typeof def.fallback) {
|
|
175
|
+
problems.push(
|
|
176
|
+
`flag "${key}" is retired to a ${typeof retired.value} but its fallback is ${typeof def.fallback}. Every evaluation would return the wrong type.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (def.overridable) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`"${key}" is retired but still declares \`overridable\`. Retirement outranks the override cookie, so it can no longer be revealed that way \u2014 drop the field.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!DATE.test(retired.since)) {
|
|
185
|
+
problems.push(
|
|
186
|
+
`flag "${key}" is retired with since: "${retired.since}", which is not YYYY-MM-DD. The date is the cleanup clock, so an unparseable one silently stops it.`
|
|
187
|
+
);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const days = Math.floor(
|
|
191
|
+
(today.getTime() - (/* @__PURE__ */ new Date(`${retired.since}T00:00:00Z`)).getTime()) / 864e5
|
|
192
|
+
);
|
|
193
|
+
if (days > retiredAfterDays) {
|
|
194
|
+
warnings.push(
|
|
195
|
+
`"${key}" has been retired for ${days} days (budget ${retiredAfterDays}).
|
|
196
|
+
Its doors are dark and its code is dead: delete the components, the
|
|
197
|
+
routes, then the registry entry. Retiring is a state, not a resting place.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { problems, warnings };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/route-gate.ts
|
|
205
|
+
function matchesPrefix(pathname, prefixes) {
|
|
206
|
+
return prefixes.some(
|
|
207
|
+
(prefix) => pathname === prefix || pathname.startsWith(prefix + "/")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
function createRouteGate(options) {
|
|
211
|
+
const { resolver, routes, allow = [], unlisted } = options;
|
|
212
|
+
const entries = Object.entries(routes);
|
|
213
|
+
for (const [key, prefixes] of entries) {
|
|
214
|
+
for (const prefix of prefixes) {
|
|
215
|
+
if (allow.includes(prefix)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`launchgate: "${prefix}" is both allowed and gated by flag "${key}". The allowlist would serve it whatever the flag says, so the flag is dead.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!(key in resolver.flags)) {
|
|
222
|
+
throw new Error(`launchgate: routes reference unknown flag "${key}"`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function revealedPrefixes(ctx = {}) {
|
|
226
|
+
const revealed = await Promise.all(
|
|
227
|
+
entries.map(
|
|
228
|
+
async ([key, prefixes]) => await resolver.resolve(key, ctx) ? prefixes : []
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
return revealed.flat();
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
revealedPrefixes,
|
|
235
|
+
async permittedPrefixes(ctx) {
|
|
236
|
+
return [...allow, ...await revealedPrefixes(ctx)];
|
|
237
|
+
},
|
|
238
|
+
async isPermitted(pathname, ctx) {
|
|
239
|
+
if (matchesPrefix(pathname, allow)) return true;
|
|
240
|
+
if (matchesPrefix(pathname, await revealedPrefixes(ctx))) return true;
|
|
241
|
+
const flagged = entries.flatMap(([, prefixes]) => prefixes);
|
|
242
|
+
if (matchesPrefix(pathname, flagged)) return false;
|
|
243
|
+
return unlisted === "allow";
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/testing.ts
|
|
249
|
+
function withFlags(flags, values = {}) {
|
|
250
|
+
return createResolver({
|
|
251
|
+
flags,
|
|
252
|
+
sources: [
|
|
253
|
+
{
|
|
254
|
+
name: "withFlags",
|
|
255
|
+
decide: ({ key }) => values[key]
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
export {
|
|
261
|
+
DEFAULT_OVERRIDE_COOKIE,
|
|
262
|
+
auditRegistry,
|
|
263
|
+
cookieOverride,
|
|
264
|
+
createResolver,
|
|
265
|
+
createRouteGate,
|
|
266
|
+
defineFlags,
|
|
267
|
+
envDefault,
|
|
268
|
+
envOverride,
|
|
269
|
+
matchesPrefix,
|
|
270
|
+
readOverrides,
|
|
271
|
+
signOverrides,
|
|
272
|
+
subjectStore,
|
|
273
|
+
withFlags
|
|
274
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { b as FlagRegistry, R as Resolver, E as EvaluationContext } from '../resolver-B2cstRTq.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds an `EvaluationContext` that reads cookies from the incoming request.
|
|
6
|
+
*
|
|
7
|
+
* **This opts the caller into dynamic rendering** — `cookies()` always does. On
|
|
8
|
+
* a page that is already dynamic (anything authenticated) that costs nothing; on
|
|
9
|
+
* a page you need statically rendered, don't call it, and see `staticVariant`
|
|
10
|
+
* in the README instead.
|
|
11
|
+
*/
|
|
12
|
+
declare function requestContext(extra?: EvaluationContext): Promise<EvaluationContext>;
|
|
13
|
+
/**
|
|
14
|
+
* Route-handler guard. 404, not 403: a feature you don't have shouldn't
|
|
15
|
+
* advertise its own existence.
|
|
16
|
+
*
|
|
17
|
+
* const gate = await requireFlag(resolver, "network", { targetingKey: orgId });
|
|
18
|
+
* if (gate) return gate;
|
|
19
|
+
*/
|
|
20
|
+
declare function requireFlag<T extends FlagRegistry, K extends keyof T & string>(resolver: Resolver<T>, key: K, ctx?: EvaluationContext): Promise<NextResponse | null>;
|
|
21
|
+
interface OverrideRouteOptions<T extends FlagRegistry> {
|
|
22
|
+
resolver: Resolver<T>;
|
|
23
|
+
/** HMAC secret. Same value `cookieOverride` was given. Never appears in a URL. */
|
|
24
|
+
secret: string;
|
|
25
|
+
/** Shared secret presented as `?token=` to use this route. Must differ from `secret`. */
|
|
26
|
+
accessToken: string;
|
|
27
|
+
cookieName?: string;
|
|
28
|
+
/** Cookie lifetime in seconds. Default 30 days. */
|
|
29
|
+
maxAge?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `GET /__flags?token=…&flag=newHomepage&value=on` — sets the signed override
|
|
33
|
+
* cookie, so you see hidden work on the real production site with no account and
|
|
34
|
+
* no database. `&clear=1` drops everything; omitting `value` clears one flag.
|
|
35
|
+
*
|
|
36
|
+
* Mount it at a path you don't advertise, and give it a real `accessToken`.
|
|
37
|
+
*/
|
|
38
|
+
declare function createOverrideRoute<T extends FlagRegistry>(options: OverrideRouteOptions<T>): {
|
|
39
|
+
GET: (request: Request) => Promise<NextResponse>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { type OverrideRouteOptions, createOverrideRoute, requestContext, requireFlag };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_OVERRIDE_COOKIE,
|
|
3
|
+
readOverrides,
|
|
4
|
+
signOverrides
|
|
5
|
+
} from "../chunk-34MIJ7KP.js";
|
|
6
|
+
|
|
7
|
+
// src/next/index.ts
|
|
8
|
+
import { cookies } from "next/headers";
|
|
9
|
+
import { NextResponse } from "next/server";
|
|
10
|
+
async function requestContext(extra = {}) {
|
|
11
|
+
const jar = await cookies();
|
|
12
|
+
return { ...extra, cookie: (name) => jar.get(name)?.value };
|
|
13
|
+
}
|
|
14
|
+
async function requireFlag(resolver, key, ctx) {
|
|
15
|
+
const value = await resolver.resolve(key, ctx);
|
|
16
|
+
return value === false || value === void 0 ? NextResponse.json({ error: "Not found" }, { status: 404 }) : null;
|
|
17
|
+
}
|
|
18
|
+
function timingSafeEqual(a, b) {
|
|
19
|
+
if (a.length !== b.length) return false;
|
|
20
|
+
let diff = 0;
|
|
21
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
22
|
+
return diff === 0;
|
|
23
|
+
}
|
|
24
|
+
function parseAs(raw, fallback) {
|
|
25
|
+
if (typeof fallback === "boolean") {
|
|
26
|
+
const v = raw.trim().toLowerCase();
|
|
27
|
+
if (v === "on" || v === "true" || v === "1") return true;
|
|
28
|
+
if (v === "off" || v === "false" || v === "0") return false;
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
if (typeof fallback === "number") {
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
return Number.isFinite(n) ? n : void 0;
|
|
34
|
+
}
|
|
35
|
+
return raw;
|
|
36
|
+
}
|
|
37
|
+
function createOverrideRoute(options) {
|
|
38
|
+
const { resolver, secret, accessToken } = options;
|
|
39
|
+
const cookieName = options.cookieName ?? DEFAULT_OVERRIDE_COOKIE;
|
|
40
|
+
const maxAge = options.maxAge ?? 60 * 60 * 24 * 30;
|
|
41
|
+
async function GET(request) {
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
const token = url.searchParams.get("token") ?? "";
|
|
44
|
+
if (!timingSafeEqual(token, accessToken)) {
|
|
45
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
const jar = await cookies();
|
|
48
|
+
const current = jar.get(cookieName)?.value;
|
|
49
|
+
let overrides = (current ? await readOverrides(current, secret) : void 0) ?? {};
|
|
50
|
+
if (url.searchParams.get("clear") !== null) {
|
|
51
|
+
overrides = {};
|
|
52
|
+
} else {
|
|
53
|
+
const key = url.searchParams.get("flag");
|
|
54
|
+
if (!key) {
|
|
55
|
+
return NextResponse.json({ error: "flag is required" }, { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
const def = resolver.flags[key];
|
|
58
|
+
if (!def) {
|
|
59
|
+
return NextResponse.json({ error: `unknown flag "${key}"` }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
if (!def.overridable) {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: `flag "${key}" is not overridable` },
|
|
64
|
+
{ status: 400 }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const raw = url.searchParams.get("value");
|
|
68
|
+
if (raw === null) {
|
|
69
|
+
delete overrides[key];
|
|
70
|
+
} else {
|
|
71
|
+
const value = parseAs(raw, def.fallback);
|
|
72
|
+
if (value === void 0) {
|
|
73
|
+
return NextResponse.json(
|
|
74
|
+
{ error: `"${raw}" is not a valid value for "${key}"` },
|
|
75
|
+
{ status: 400 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
overrides[key] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const body = { overrides };
|
|
82
|
+
const response = NextResponse.json(body);
|
|
83
|
+
if (Object.keys(overrides).length === 0) {
|
|
84
|
+
response.cookies.delete(cookieName);
|
|
85
|
+
} else {
|
|
86
|
+
response.cookies.set(cookieName, await signOverrides(overrides, secret), {
|
|
87
|
+
httpOnly: true,
|
|
88
|
+
sameSite: "lax",
|
|
89
|
+
secure: url.protocol === "https:",
|
|
90
|
+
path: "/",
|
|
91
|
+
maxAge
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return response;
|
|
95
|
+
}
|
|
96
|
+
return { GET };
|
|
97
|
+
}
|
|
98
|
+
export {
|
|
99
|
+
createOverrideRoute,
|
|
100
|
+
requestContext,
|
|
101
|
+
requireFlag
|
|
102
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** What a flag can resolve to. Boolean is the common case, not the only one (Decision 1). */
|
|
2
|
+
type FlagValue = boolean | string | number;
|
|
3
|
+
/**
|
|
4
|
+
* A flag's retirement — the terminal state of its lifecycle (Decision 11).
|
|
5
|
+
*
|
|
6
|
+
* Presence is the retirement: a def carrying this resolves to `value` and **no
|
|
7
|
+
* source is consulted**, not the env kill switch and not a signed override
|
|
8
|
+
* cookie. That ordering is the point. A retirement is a decision someone
|
|
9
|
+
* committed to git, so the only way to reverse it is to revert the commit —
|
|
10
|
+
* never a leftover `FLAG_X=on` secret or a cookie an engineer is still holding.
|
|
11
|
+
*
|
|
12
|
+
* The entry **stays in the registry** after retirement, which is Unleash's
|
|
13
|
+
* archive-rather-than-delete rule and buys the same property: the key cannot be
|
|
14
|
+
* silently reused by a new feature (a duplicate key in the registry literal is
|
|
15
|
+
* a type error), so a stale override can never attach to something it was never
|
|
16
|
+
* about. Delete the entry only once its code is gone.
|
|
17
|
+
*/
|
|
18
|
+
interface Retirement<T extends FlagValue = FlagValue> {
|
|
19
|
+
/**
|
|
20
|
+
* When retirement was decided, `YYYY-MM-DD`. Required, because the clock is
|
|
21
|
+
* the whole point: retired code that nobody deletes is the flag debt this
|
|
22
|
+
* state exists to make visible. `auditRegistry` warns once it is older than
|
|
23
|
+
* the budget.
|
|
24
|
+
*/
|
|
25
|
+
since: string;
|
|
26
|
+
/**
|
|
27
|
+
* What every evaluation returns from now on — and the *disposition record*:
|
|
28
|
+
* Unleash makes you declare an outcome when completing a flag, because the
|
|
29
|
+
* cleanup PR has to know which branch of the code survives. `false` retires a
|
|
30
|
+
* feature outright; `"v1"` on a dynamic-config flag says v1 is what the code
|
|
31
|
+
* keeps.
|
|
32
|
+
*/
|
|
33
|
+
value: T;
|
|
34
|
+
}
|
|
35
|
+
interface FlagDef<T extends FlagValue = FlagValue> {
|
|
36
|
+
/**
|
|
37
|
+
* The value used when no source decides, and when a source throws.
|
|
38
|
+
*
|
|
39
|
+
* Per flag, never global (Decision 3): a work-in-progress gate's safe state is
|
|
40
|
+
* `false`, but a kill switch on a year-old feature's safe state is `true` —
|
|
41
|
+
* failing that one closed would take down working functionality because a
|
|
42
|
+
* database blipped.
|
|
43
|
+
*/
|
|
44
|
+
fallback: T;
|
|
45
|
+
/** Why this flag exists. Greppability plus this line is the whole flag-debt mitigation. */
|
|
46
|
+
description: string;
|
|
47
|
+
/** May a signed visitor cookie force this flag? Default false — see `cookieOverride`. */
|
|
48
|
+
overridable?: boolean;
|
|
49
|
+
/** Per-environment value, consulted by the `envDefault` source. */
|
|
50
|
+
byEnv?: Record<string, T>;
|
|
51
|
+
/**
|
|
52
|
+
* Retired: resolve to `retired.value` and consult no source at all. See
|
|
53
|
+
* `Retirement` — this outranks every source, including the kill switch.
|
|
54
|
+
*/
|
|
55
|
+
retired?: Retirement<T>;
|
|
56
|
+
/**
|
|
57
|
+
* Projects may hang their own fields here — a stage, a tier, an owner — and
|
|
58
|
+
* read them back inside a custom source. The registry keeps their real types
|
|
59
|
+
* (`flags.network.stage` is a `string`); only the erased view a `Source` sees
|
|
60
|
+
* widens to `unknown`.
|
|
61
|
+
*/
|
|
62
|
+
[projectField: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
type FlagRegistry = Record<string, FlagDef>;
|
|
65
|
+
/**
|
|
66
|
+
* The type a flag resolves to.
|
|
67
|
+
*
|
|
68
|
+
* `fallback: false` infers as the *literal* `false` rather than `boolean`,
|
|
69
|
+
* because `boolean` is itself `true | false` and TypeScript preserves a literal
|
|
70
|
+
* whose contextual type contains it. Left alone, every boolean flag would be
|
|
71
|
+
* unable to hold the opposite value. Strings and numbers do not have this
|
|
72
|
+
* problem (their contextual types are not literal unions), so they keep whatever
|
|
73
|
+
* the author declared — which is what makes `fallback: "v1" as "v1" | "v2"` work.
|
|
74
|
+
*/
|
|
75
|
+
type FlagValueOf<T extends FlagRegistry, K extends keyof T> = T[K]["fallback"] extends boolean ? boolean : T[K]["fallback"];
|
|
76
|
+
/**
|
|
77
|
+
* Data a source may target on. Named for OpenFeature's evaluation context, and
|
|
78
|
+
* `targetingKey` is its field — the CNCF spec every major SDK implements, so
|
|
79
|
+
* this reads as a familiar dialect rather than a private vocabulary.
|
|
80
|
+
*
|
|
81
|
+
* `targetingKey` is **optional** by design, where OpenFeature makes it merely
|
|
82
|
+
* conventional: an anonymous visitor to a static site has none, and a launch
|
|
83
|
+
* gate must still resolve for them.
|
|
84
|
+
*/
|
|
85
|
+
interface EvaluationContext {
|
|
86
|
+
/** Who is being evaluated — a user id, org id, device id. Absent for anonymous visitors. */
|
|
87
|
+
targetingKey?: string;
|
|
88
|
+
/** Reads a request cookie. Supplied by a framework adapter; absent means "no cookies here". */
|
|
89
|
+
cookie?: (name: string) => string | undefined;
|
|
90
|
+
/** Anything else a project-supplied source needs. */
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
/** @deprecated Renamed to `EvaluationContext` in 0.3.0 to match OpenFeature. */
|
|
94
|
+
type Context = EvaluationContext;
|
|
95
|
+
/** A source's answer. `undefined` means *undecided — ask the next source* (Decision 2). */
|
|
96
|
+
type Decision = FlagValue | undefined;
|
|
97
|
+
interface FlagMeta {
|
|
98
|
+
key: string;
|
|
99
|
+
def: FlagDef;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* One link in the resolution chain. Sources are typed against `unknown` values
|
|
103
|
+
* rather than the registry's inferred types (Decision 8) — the call site keeps
|
|
104
|
+
* its types, the four built-in sources give theirs up.
|
|
105
|
+
*/
|
|
106
|
+
interface Source {
|
|
107
|
+
/** Reported to `onError` when this source throws. */
|
|
108
|
+
readonly name: string;
|
|
109
|
+
decide(flag: FlagMeta, ctx: Context): Decision | Promise<Decision>;
|
|
110
|
+
}
|
|
111
|
+
interface SourceErrorInfo {
|
|
112
|
+
flag: string;
|
|
113
|
+
source: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Declares a project's flags. Identity at runtime; it exists to infer each flag's
|
|
118
|
+
* value type so `resolve()` is typed at the call site.
|
|
119
|
+
*
|
|
120
|
+
* The registry lives in the project that reads the flags, not in a table and not
|
|
121
|
+
* in library config (Decision 4): flags ship with the code they gate, so deleting
|
|
122
|
+
* one is a pull request and a key nobody reads is visibly dead.
|
|
123
|
+
*/
|
|
124
|
+
declare function defineFlags<T extends FlagRegistry>(flags: T): T;
|
|
125
|
+
interface ResolverOptions<T extends FlagRegistry> {
|
|
126
|
+
flags: T;
|
|
127
|
+
/**
|
|
128
|
+
* Consulted in order; the first definite answer wins. Recommended ordering:
|
|
129
|
+
* `envOverride` (kill switch), `cookieOverride`, project sources (grants,
|
|
130
|
+
* opt-in, entitlement), `envDefault`. The flag's `fallback` is terminal and is
|
|
131
|
+
* not a source, so it cannot be left out.
|
|
132
|
+
*/
|
|
133
|
+
sources: Source[];
|
|
134
|
+
/** Called when a source throws or answers with the wrong type. */
|
|
135
|
+
onError?: (error: unknown, info: SourceErrorInfo) => void;
|
|
136
|
+
}
|
|
137
|
+
/** Every flag in a registry, evaluated. */
|
|
138
|
+
type ResolvedFlags<T extends FlagRegistry> = {
|
|
139
|
+
[K in keyof T]: FlagValueOf<T, K>;
|
|
140
|
+
};
|
|
141
|
+
interface Resolver<T extends FlagRegistry> {
|
|
142
|
+
resolve<K extends keyof T & string>(key: K, ctx?: EvaluationContext): Promise<FlagValueOf<T, K>>;
|
|
143
|
+
/**
|
|
144
|
+
* Evaluate the whole registry at once — LaunchDarkly's `allFlagsState()`
|
|
145
|
+
* pattern: resolve server-side, hand the map to the client, and the UI never
|
|
146
|
+
* flickers because it never had to ask.
|
|
147
|
+
*
|
|
148
|
+
* Resolution is concurrent and shares the context object, so subject-backed
|
|
149
|
+
* sources hit their per-request cache; with `subjectStore({ loadAll })` the
|
|
150
|
+
* whole registry costs one read.
|
|
151
|
+
*
|
|
152
|
+
* Pass the **values** to a client, never the registry — flag keys name
|
|
153
|
+
* unshipped work, and a viewer who can't see a surface shouldn't learn its
|
|
154
|
+
* name from the payload.
|
|
155
|
+
*/
|
|
156
|
+
resolveAll(ctx?: EvaluationContext): Promise<ResolvedFlags<T>>;
|
|
157
|
+
readonly flags: T;
|
|
158
|
+
}
|
|
159
|
+
declare function createResolver<T extends FlagRegistry>(options: ResolverOptions<T>): Resolver<T>;
|
|
160
|
+
|
|
161
|
+
export { type Context as C, type Decision as D, type EvaluationContext as E, type FlagValue as F, type Resolver as R, type Source as S, type FlagMeta as a, type FlagRegistry as b, type FlagValueOf as c, type FlagDef as d, type ResolvedFlags as e, type ResolverOptions as f, type Retirement as g, type SourceErrorInfo as h, createResolver as i, defineFlags as j };
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidlee49/launchgate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A launch-gate library: hide work in progress, kill a shipped feature, no platform required.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./next": {
|
|
16
|
+
"types": "./dist/next/index.d.ts",
|
|
17
|
+
"import": "./dist/next/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"tsup": "^8.5.0",
|
|
22
|
+
"typescript": "^5.9.2",
|
|
23
|
+
"vitest": "^3.2.4",
|
|
24
|
+
"next": "^16.0.0",
|
|
25
|
+
"react": "^19.0.0",
|
|
26
|
+
"react-dom": "^19.0.0",
|
|
27
|
+
"@types/react": "^19.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"next": ">=15"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"next": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/davidlee49/launchgate.git"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"check": "tsc --noEmit && vitest run && tsup",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
49
|
+
}
|
|
6
50
|
}
|