@stratal/feature-flags 0.0.0-canary-e5681b8
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/CHANGELOG.md +131 -0
- package/README.md +90 -0
- package/dist/index.d.mts +185 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +275 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react.d.mts +22 -0
- package/dist/react.d.mts.map +1 -0
- package/dist/react.mjs +16 -0
- package/dist/react.mjs.map +1 -0
- package/dist/types-D-uV0zlC.d.mts +70 -0
- package/dist/types-D-uV0zlC.d.mts.map +1 -0
- package/package.json +101 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @stratal/feature-flags
|
|
2
|
+
|
|
3
|
+
## 0.0.0-canary-e5681b8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e5681b8: Release alongside the rest of the packages; nothing changed in this one.
|
|
8
|
+
|
|
9
|
+
Every Stratal package is versioned as one fixed group, so `@stratal/feature-flags` is republished at the same version as the packages it builds on rather than being left behind. Its peer ranges are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework.
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [e5681b8]
|
|
12
|
+
- Updated dependencies [e5681b8]
|
|
13
|
+
- stratal@0.0.0-canary-e5681b8
|
|
14
|
+
- @stratal/inertia@0.0.0-canary-e5681b8
|
|
15
|
+
|
|
16
|
+
## 0.1.0
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- a753e55: Release alongside the rest of the packages; nothing changed in this one.
|
|
21
|
+
|
|
22
|
+
Every Stratal package is versioned as one fixed group, so `@stratal/feature-flags` is republished at the same version as the packages it builds on rather than being left behind. Its peer ranges are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework.
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [a753e55]
|
|
25
|
+
- Updated dependencies [a753e55]
|
|
26
|
+
- stratal@0.1.0
|
|
27
|
+
- @stratal/inertia@0.1.0
|
|
28
|
+
|
|
29
|
+
## 0.0.27
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 41a9140: Make feature flag evaluation resilient to runtime failures
|
|
34
|
+
|
|
35
|
+
Flag evaluation now returns the fallback value and logs a warning if a lookup throws — for example when a remote-binding dev tunnel drops — rather than failing the request. Detail methods return an `ERROR` reason with the fallback in these cases.
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [41a9140]
|
|
38
|
+
- stratal@0.0.27
|
|
39
|
+
- @stratal/inertia@0.0.27
|
|
40
|
+
|
|
41
|
+
## 0.0.26
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [ab95f52]
|
|
46
|
+
- Updated dependencies [ab95f52]
|
|
47
|
+
- Updated dependencies [bb6d3b9]
|
|
48
|
+
- stratal@0.0.26
|
|
49
|
+
- @stratal/inertia@0.0.26
|
|
50
|
+
|
|
51
|
+
## 0.0.25
|
|
52
|
+
|
|
53
|
+
### Patch Changes
|
|
54
|
+
|
|
55
|
+
- Updated dependencies [e93db60]
|
|
56
|
+
- Updated dependencies [e93db60]
|
|
57
|
+
- stratal@0.0.25
|
|
58
|
+
- @stratal/inertia@0.0.25
|
|
59
|
+
|
|
60
|
+
## 0.0.24
|
|
61
|
+
|
|
62
|
+
### Patch Changes
|
|
63
|
+
|
|
64
|
+
- Updated dependencies [10cf223]
|
|
65
|
+
- @stratal/inertia@0.0.24
|
|
66
|
+
- stratal@0.0.24
|
|
67
|
+
|
|
68
|
+
## 0.0.23
|
|
69
|
+
|
|
70
|
+
### Patch Changes
|
|
71
|
+
|
|
72
|
+
- 13b0e8d: Add `@stratal/feature-flags` — Cloudflare Flagship feature flags via the native Worker binding API.
|
|
73
|
+
- `FeatureFlagModule.forRoot({ apps: [{ binding, flags }], default, context })` with a declare-once flag manifest, manifest defaults, a per-request evaluation-context resolver, and multi-app support via `FeatureFlagService.use(binding)`.
|
|
74
|
+
- `FeatureFlagShareMiddleware` shares evaluated flags to Inertia pages as the `featureFlags` prop; register it yourself (scoped to page controllers via `router.middleware(...)` or app-wide via `router.use(...)`) so a stalled Flagship binding can't block unrelated routes. Typed `useFlag` / `useFeatureFlags` hooks on `@stratal/feature-flags/react`. No runtime dependency on `@stratal/inertia`.
|
|
75
|
+
- `@stratal/inertia`: expose a generic `ctx.share(key, value)` macro on `RouterContext` so middleware and packages can contribute per-request shared props.
|
|
76
|
+
- `@stratal/framework`: add a `ctx.user()` macro on `RouterContext` (shorthand for `AuthContext.requireUser()`).
|
|
77
|
+
|
|
78
|
+
- 13b0e8d: Fix correctness and security issues found in review.
|
|
79
|
+
|
|
80
|
+
Queue:
|
|
81
|
+
- Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job.
|
|
82
|
+
- Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured).
|
|
83
|
+
- Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly.
|
|
84
|
+
- `queue:retry --all` / `queue:purge --all --queue` collect matching keys before deleting, so cursor pagination no longer skips jobs; `queue:failed --queue --limit` now counts matching jobs rather than scanned keys.
|
|
85
|
+
- Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent.
|
|
86
|
+
|
|
87
|
+
Email (SMTP):
|
|
88
|
+
- Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector).
|
|
89
|
+
- Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected.
|
|
90
|
+
- AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords.
|
|
91
|
+
- Add a response timeout so a hung SMTP server can't wedge the worker; QUIT/socket close are now best-effort and never mask a successful send.
|
|
92
|
+
- MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync).
|
|
93
|
+
|
|
94
|
+
Inertia SEO:
|
|
95
|
+
- `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally.
|
|
96
|
+
- Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output.
|
|
97
|
+
- Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes).
|
|
98
|
+
|
|
99
|
+
Feature flags:
|
|
100
|
+
- `FeatureFlagService.use()` binds the target app exactly once.
|
|
101
|
+
|
|
102
|
+
Database (framework):
|
|
103
|
+
- The reentrant `$transaction` proxy forwards the receiver for non-transaction property access.
|
|
104
|
+
|
|
105
|
+
Testing:
|
|
106
|
+
- `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match.
|
|
107
|
+
|
|
108
|
+
DI:
|
|
109
|
+
- Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly.
|
|
110
|
+
- Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack.
|
|
111
|
+
- `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`.
|
|
112
|
+
- Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary.
|
|
113
|
+
|
|
114
|
+
Quarry dev runtime:
|
|
115
|
+
- Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env.<env>.local` into `process.env` for full parity.
|
|
116
|
+
- The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface.
|
|
117
|
+
|
|
118
|
+
- Updated dependencies [13b0e8d]
|
|
119
|
+
- Updated dependencies [13b0e8d]
|
|
120
|
+
- Updated dependencies [13b0e8d]
|
|
121
|
+
- Updated dependencies [13b0e8d]
|
|
122
|
+
- Updated dependencies [13b0e8d]
|
|
123
|
+
- Updated dependencies [13b0e8d]
|
|
124
|
+
- Updated dependencies [13b0e8d]
|
|
125
|
+
- Updated dependencies [13b0e8d]
|
|
126
|
+
- Updated dependencies [13b0e8d]
|
|
127
|
+
- Updated dependencies [13b0e8d]
|
|
128
|
+
- Updated dependencies [13b0e8d]
|
|
129
|
+
- Updated dependencies [be813bc]
|
|
130
|
+
- stratal@0.0.23
|
|
131
|
+
- @stratal/inertia@0.0.23
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @stratal/feature-flags
|
|
2
|
+
|
|
3
|
+
[Cloudflare Flagship](https://developers.cloudflare.com/flagship/) feature flags for the [Stratal](https://stratal.dev) framework, using the native Worker **binding API** — with opt-in [Inertia.js](https://inertiajs.com) sharing and typed React hooks.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@stratal/feature-flags)
|
|
6
|
+
[](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://www.npmjs.com/package/@stratal/feature-flags)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
10
|
+
[](https://github.com/strataljs/stratal/pulls)
|
|
11
|
+
[](https://github.com/strataljs/stratal)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i @stratal/feature-flags
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add the Flagship binding to your Wrangler config and run `npx wrangler types`:
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
// wrangler.jsonc
|
|
23
|
+
{ "flagship": [{ "binding": "FLAGS", "app_id": "<APP_ID>" }] }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { FeatureFlagModule } from '@stratal/feature-flags'
|
|
30
|
+
|
|
31
|
+
@Module({
|
|
32
|
+
imports: [
|
|
33
|
+
FeatureFlagModule.forRoot({
|
|
34
|
+
apps: [{ binding: 'FLAGS', flags: { 'new-checkout': false } }],
|
|
35
|
+
context: (ctx) => ({ userId: ctx.user().id }), // ctx.user() from @stratal/framework
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
export class AppModule {}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A Worker can bind to several Flagship apps — list each one under `apps`, and set `default` to pick the binding the injected `FeatureFlagService` uses. Flagship has no enumeration API, so the `flags` manifest declares what you intend to evaluate and share, and its values double as defaults.
|
|
43
|
+
|
|
44
|
+
To expose flags to an Inertia frontend, register `FeatureFlagShareMiddleware` where you want them — scoped to your page-rendering controllers (`router.middleware(...)`) or app-wide (`router.use(...)`).
|
|
45
|
+
|
|
46
|
+
Evaluate on the server:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const enabled = await this.flags.getBooleanValue('new-checkout') // uses manifest default
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`getStringValue()`, `getNumberValue()` and `getObjectValue()` cover the other flag types, each with a `*Details()` variant that returns the full evaluation details. `use(binding)` switches to another bound app, and `all()` evaluates the whole manifest at once.
|
|
53
|
+
|
|
54
|
+
Read on the client:
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { useFeatureFlags, useFlag } from '@stratal/feature-flags/react'
|
|
58
|
+
|
|
59
|
+
const showNewCheckout = useFlag('new-checkout')
|
|
60
|
+
const flags = useFeatureFlags()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Typed flag keys
|
|
64
|
+
|
|
65
|
+
Augment `FeatureFlagRegistry` once and both the service and the hooks get typed keys:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
declare module '@stratal/feature-flags' {
|
|
69
|
+
interface FeatureFlagRegistry {
|
|
70
|
+
'new-checkout': boolean
|
|
71
|
+
'checkout-flow': string
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
See the framework docs at **[stratal.dev](https://stratal.dev)** for the full API.
|
|
79
|
+
|
|
80
|
+
## Support the project
|
|
81
|
+
|
|
82
|
+
If Stratal is useful to you, **[star the repository](https://github.com/strataljs/stratal)** — it is the simplest way to help others find it.
|
|
83
|
+
|
|
84
|
+
## Maintainer
|
|
85
|
+
|
|
86
|
+
Built and maintained by **Temitayo Fadojutimi** — [@adesege_](https://x.com/adesege_).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { a as FlagValue, i as FlagManifest, n as FeatureFlagModuleOptions, o as FlagshipBindingName, r as FeatureFlagRegistry, t as FeatureFlagApp } from "./types-D-uV0zlC.mjs";
|
|
2
|
+
import { AsyncModuleOptions, DynamicModule } from "stratal/module";
|
|
3
|
+
import { LoggerService } from "stratal/logger";
|
|
4
|
+
import { Middleware, Next, RouterContext } from "stratal/router";
|
|
5
|
+
import { ApplicationError } from "stratal/errors";
|
|
6
|
+
import { StratalEnv } from "stratal";
|
|
7
|
+
//#region src/feature-flags.module.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* Feature Flag Module
|
|
10
|
+
*
|
|
11
|
+
* Evaluates Cloudflare Flagship feature flags through the native Worker binding.
|
|
12
|
+
* Declare your apps (and the flags you use) once; inject {@link FeatureFlagService}
|
|
13
|
+
* to evaluate them.
|
|
14
|
+
*
|
|
15
|
+
* To expose flags to an Inertia frontend, register {@link FeatureFlagShareMiddleware}
|
|
16
|
+
* yourself — scope it to the controllers that render pages (`router.middleware(...)`)
|
|
17
|
+
* or app-wide (`router.use(...)`) from a module's `configureRoutes`. It is not
|
|
18
|
+
* registered for you, so a stalled Flagship binding never blocks unrelated routes.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* FeatureFlagModule.forRoot({
|
|
23
|
+
* apps: [{ binding: 'FLAGS', flags: { 'new-checkout': false } }],
|
|
24
|
+
* context: (ctx) => ({ userId: ctx.user().id }), // ctx.user() from @stratal/framework
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* // Or async, from config namespaces:
|
|
28
|
+
* FeatureFlagModule.forRootAsync({
|
|
29
|
+
* inject: [flagsConfig.KEY],
|
|
30
|
+
* useFactory: (cfg) => ({ apps: cfg.apps, default: cfg.default }),
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare class FeatureFlagModule {
|
|
35
|
+
/** Configure with static options. */
|
|
36
|
+
static forRoot(options: FeatureFlagModuleOptions): DynamicModule;
|
|
37
|
+
/** Configure with an async factory (when options depend on other services). */
|
|
38
|
+
static forRootAsync(options: AsyncModuleOptions<FeatureFlagModuleOptions>): DynamicModule;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/services/feature-flag.service.d.ts
|
|
42
|
+
/**
|
|
43
|
+
* Feature Flag Service
|
|
44
|
+
*
|
|
45
|
+
* Type-safe wrapper around a Cloudflare Flagship binding (`env.FLAGS`). Mirrors
|
|
46
|
+
* the binding's evaluation methods 1:1, with two ergonomic additions:
|
|
47
|
+
*
|
|
48
|
+
* - **Manifest defaults** — when you omit a default, the value declared in the
|
|
49
|
+
* app's `flags` manifest is used (an explicit argument always wins).
|
|
50
|
+
* - **Default context** — the module's `context` resolver is merged into every
|
|
51
|
+
* evaluation (per-call context overrides it). Resolved from the current
|
|
52
|
+
* request; skipped automatically outside request scope.
|
|
53
|
+
*
|
|
54
|
+
* Switch to another Flagship app with {@link use}. Evaluation never throws —
|
|
55
|
+
* the binding returns the default value on evaluation errors, and the service
|
|
56
|
+
* catches everything else (e.g. a dropped remote-binding tunnel in local dev)
|
|
57
|
+
* and returns the same fallback, logging a warning.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* @inject(FEATURE_FLAG_TOKENS.FeatureFlagService)
|
|
62
|
+
* private readonly flags: FeatureFlagService
|
|
63
|
+
*
|
|
64
|
+
* const enabled = await this.flags.getBooleanValue('new-checkout') // manifest default
|
|
65
|
+
* const layout = await this.flags.use('EXPERIMENT_FLAGS').getStringValue('layout', 'v1')
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* @see https://developers.cloudflare.com/flagship/binding/
|
|
69
|
+
*/
|
|
70
|
+
export declare class FeatureFlagService {
|
|
71
|
+
private readonly options;
|
|
72
|
+
private readonly env;
|
|
73
|
+
private readonly routerContext;
|
|
74
|
+
private readonly logger;
|
|
75
|
+
private readonly apps;
|
|
76
|
+
private bindingName;
|
|
77
|
+
private binding;
|
|
78
|
+
private manifest;
|
|
79
|
+
constructor(options: FeatureFlagModuleOptions, env: StratalEnv, routerContext: RouterContext | undefined, logger: LoggerService | undefined, initialBinding?: string);
|
|
80
|
+
/**
|
|
81
|
+
* Switch to a different configured Flagship app.
|
|
82
|
+
*
|
|
83
|
+
* Returns a new immutable instance bound to `binding`; the original is
|
|
84
|
+
* unchanged. The binding must be declared in the module's `apps`.
|
|
85
|
+
*/
|
|
86
|
+
use(binding: FlagshipBindingName): FeatureFlagService;
|
|
87
|
+
/** The binding name this instance currently targets. */
|
|
88
|
+
get app(): string;
|
|
89
|
+
/** Returns the raw flag value without type checking. */
|
|
90
|
+
get(flagKey: string, defaultValue?: unknown, context?: FlagshipEvaluationContext): Promise<unknown>;
|
|
91
|
+
/** Returns the flag value as a `boolean`. */
|
|
92
|
+
getBooleanValue(flagKey: string, defaultValue?: boolean, context?: FlagshipEvaluationContext): Promise<boolean>;
|
|
93
|
+
/** Returns the flag value as a `string`. */
|
|
94
|
+
getStringValue(flagKey: string, defaultValue?: string, context?: FlagshipEvaluationContext): Promise<string>;
|
|
95
|
+
/** Returns the flag value as a `number`. */
|
|
96
|
+
getNumberValue(flagKey: string, defaultValue?: number, context?: FlagshipEvaluationContext): Promise<number>;
|
|
97
|
+
/** Returns the flag value as a typed object. */
|
|
98
|
+
getObjectValue<T extends object>(flagKey: string, defaultValue?: T, context?: FlagshipEvaluationContext): Promise<T>;
|
|
99
|
+
/** Returns the `boolean` flag value with evaluation metadata. */
|
|
100
|
+
getBooleanDetails(flagKey: string, defaultValue?: boolean, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<boolean>>;
|
|
101
|
+
/** Returns the `string` flag value with evaluation metadata. */
|
|
102
|
+
getStringDetails(flagKey: string, defaultValue?: string, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<string>>;
|
|
103
|
+
/** Returns the `number` flag value with evaluation metadata. */
|
|
104
|
+
getNumberDetails(flagKey: string, defaultValue?: number, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<number>>;
|
|
105
|
+
/** Returns the typed object flag value with evaluation metadata. */
|
|
106
|
+
getObjectDetails<T extends object>(flagKey: string, defaultValue?: T, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<T>>;
|
|
107
|
+
/**
|
|
108
|
+
* Evaluates every flag declared in the current app's manifest and returns a
|
|
109
|
+
* `{ key: value }` map. The evaluation method is chosen from each declared
|
|
110
|
+
* default's type. Powers `FeatureFlagShareMiddleware`.
|
|
111
|
+
*/
|
|
112
|
+
all(context?: FlagshipEvaluationContext): Promise<Record<string, FlagValue>>;
|
|
113
|
+
private bindTo;
|
|
114
|
+
/** Resolves the merged evaluation context (default context + per-call override). */
|
|
115
|
+
private context;
|
|
116
|
+
/** Picks the default: explicit arg, then manifest, then the type's zero value. */
|
|
117
|
+
private fallback;
|
|
118
|
+
/** Evaluates a single flag, choosing the method from the declared default's type. */
|
|
119
|
+
private evaluate;
|
|
120
|
+
/**
|
|
121
|
+
* Runs an evaluation and absorbs any failure into the fallback. The binding
|
|
122
|
+
* already returns the default on evaluation errors, but the call itself can
|
|
123
|
+
* still reject — e.g. when a `remote: true` binding's dev-proxy WebSocket
|
|
124
|
+
* tunnel drops. A flag lookup must never take the request down with it.
|
|
125
|
+
*/
|
|
126
|
+
private safe;
|
|
127
|
+
/** Synthesizes the details shape the binding would return for a failed evaluation. */
|
|
128
|
+
private errorDetails;
|
|
129
|
+
/** Extracts a human-readable message from an unknown thrown value. */
|
|
130
|
+
private message;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/feature-flag-share.middleware.d.ts
|
|
134
|
+
/**
|
|
135
|
+
* Evaluates the declared flag manifest for the default app and shares it as the
|
|
136
|
+
* `featureFlags` prop on every Inertia page rendered during the request.
|
|
137
|
+
*
|
|
138
|
+
* Only runs on `GET` requests — page renders (full visits and partial reloads)
|
|
139
|
+
* are always `GET`, so mutating API calls don't trigger evaluation. No-ops when
|
|
140
|
+
* Inertia is not installed (`ctx.share` absent).
|
|
141
|
+
*
|
|
142
|
+
* Register it yourself, scoped to where flags are actually needed — a Flagship
|
|
143
|
+
* stall then only affects those routes, not the whole app:
|
|
144
|
+
*
|
|
145
|
+
* ```typescript
|
|
146
|
+
* configureRoutes(router: Router): void {
|
|
147
|
+
* // only the controllers that render flag-aware pages
|
|
148
|
+
* router.group([DashboardController], (r) => r.middleware(FeatureFlagShareMiddleware))
|
|
149
|
+
* // ...or app-wide: router.use(FeatureFlagShareMiddleware)
|
|
150
|
+
* }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export declare class FeatureFlagShareMiddleware implements Middleware {
|
|
154
|
+
private readonly flags;
|
|
155
|
+
constructor(flags: FeatureFlagService);
|
|
156
|
+
handle(ctx: RouterContext, next: Next): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/feature-flags.tokens.d.ts
|
|
160
|
+
/**
|
|
161
|
+
* DI tokens for the feature-flags module.
|
|
162
|
+
*
|
|
163
|
+
* Use `Symbol.for(...)` so the tokens resolve to the same symbol across module
|
|
164
|
+
* boundaries (the global symbol registry).
|
|
165
|
+
*/
|
|
166
|
+
export declare const FEATURE_FLAG_TOKENS: {
|
|
167
|
+
/** The resolved {@link FeatureFlagModuleOptions}. */
|
|
168
|
+
readonly Options: symbol;
|
|
169
|
+
/** The request-scoped {@link FeatureFlagService} bound to the default app. */
|
|
170
|
+
readonly FeatureFlagService: symbol;
|
|
171
|
+
};
|
|
172
|
+
type FeatureFlagToken = (typeof FEATURE_FLAG_TOKENS)[keyof typeof FEATURE_FLAG_TOKENS];
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/feature-flags.error.d.ts
|
|
175
|
+
/**
|
|
176
|
+
* Thrown for feature-flag misconfiguration — an unknown app or a Flagship
|
|
177
|
+
* binding that is not present on the Worker environment.
|
|
178
|
+
*
|
|
179
|
+
* Note: flag *evaluation* never throws; the binding returns the supplied
|
|
180
|
+
* default value on error.
|
|
181
|
+
*/
|
|
182
|
+
export declare class FeatureFlagError extends ApplicationError {}
|
|
183
|
+
//#endregion
|
|
184
|
+
export type { FeatureFlagApp, FeatureFlagModuleOptions, FeatureFlagRegistry, FeatureFlagToken, FlagManifest, FlagValue, FlagshipBindingName };
|
|
185
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/feature-flags.module.ts","../src/services/feature-flag.service.ts","../src/feature-flag-share.middleware.ts","../src/feature-flags.tokens.ts","../src/feature-flags.error.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAqCa;;SAEJ,QAAQ,SAAS,2BAA2B;;SAU5C,aAAa,SAAS,mBAAmB,4BAA4B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBCNjE;mBAO6C;mBACJ;mBAC0B;mBACA;mBAT7D;UACT;UACA;UACA;EAER,YACwD,SAAS,0BACb,KAAK,YACqB,eAAe,2BACf,QAAQ,2BAGpF;;;;;;;EAcF,IAAI,SAAS,sBAAsB;;MAM/B;;EAOE,IAAI,iBAAiB,wBAAwB,UAAU,4BAA4B;;EAMnF,gBAAgB,iBAAiB,wBAAwB,UAAU,4BAA4B;;EAM/F,eAAe,iBAAiB,uBAAuB,UAAU,4BAA4B;;EAM7F,eAAe,iBAAiB,uBAAuB,UAAU,4BAA4B;;EAM7F,eAAe,kBAAkB,iBAAiB,eAAe,GAAG,UAAU,4BAA4B,QAAQ;;EAMlH,kBAAkB,iBAAiB,wBAAwB,UAAU,4BAA4B,QAAQ;;EAMzG,iBAAiB,iBAAiB,uBAAuB,UAAU,4BAA4B,QAAQ;;EAMvG,iBAAiB,iBAAiB,uBAAuB,UAAU,4BAA4B,QAAQ;;EAMvG,iBAAiB,kBAAkB,iBAAiB,eAAe,GAAG,UAAU,4BAA4B,QAAQ,0BAA0B;;;;;;EAU9I,IAAI,UAAU,4BAA4B,QAAQ,eAAe;UAsB/D;;UAkBM;;UAON;;UAOA;;;;;;;UAmBM;;UAYN;;UAKA;;;;;;;;;;;;;;;;;;;;;;;qBC5MG,sCAAsC;mBAEkB;EADnE,YACmE,OAAO;EAGpE,OAAO,KAAK,eAAe,MAAM,OAAO;;;;;;;;;;qBC1BnC;;WAEX;;WAEA;;KAGU,2BAA2B,kCAAkC;;;;;;;;;;qBCJ5D,yBAAyB"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { Module } from "stratal/module";
|
|
2
|
+
import { DI_TOKENS, Transient, inject } from "stratal/di";
|
|
3
|
+
import { LOGGER_TOKENS } from "stratal/logger";
|
|
4
|
+
import { ROUTER_TOKENS } from "stratal/router";
|
|
5
|
+
import { ApplicationError } from "stratal/errors";
|
|
6
|
+
//#region src/feature-flags.tokens.ts
|
|
7
|
+
/**
|
|
8
|
+
* DI tokens for the feature-flags module.
|
|
9
|
+
*
|
|
10
|
+
* Use `Symbol.for(...)` so the tokens resolve to the same symbol across module
|
|
11
|
+
* boundaries (the global symbol registry).
|
|
12
|
+
*/
|
|
13
|
+
const FEATURE_FLAG_TOKENS = {
|
|
14
|
+
/** The resolved {@link FeatureFlagModuleOptions}. */
|
|
15
|
+
Options: Symbol.for("stratal:feature-flags:options"),
|
|
16
|
+
/** The request-scoped {@link FeatureFlagService} bound to the default app. */
|
|
17
|
+
FeatureFlagService: Symbol.for("stratal:feature-flags:service")
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/feature-flags.error.ts
|
|
21
|
+
/**
|
|
22
|
+
* Thrown for feature-flag misconfiguration — an unknown app or a Flagship
|
|
23
|
+
* binding that is not present on the Worker environment.
|
|
24
|
+
*
|
|
25
|
+
* Note: flag *evaluation* never throws; the binding returns the supplied
|
|
26
|
+
* default value on error.
|
|
27
|
+
*/
|
|
28
|
+
var FeatureFlagError = class extends ApplicationError {};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
|
|
31
|
+
function __decorateMetadata(k, v) {
|
|
32
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateParam.js
|
|
36
|
+
function __decorateParam(paramIndex, decorator) {
|
|
37
|
+
return function(target, key) {
|
|
38
|
+
decorator(target, key, paramIndex);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
|
|
43
|
+
function __decorate(decorators, target, key, desc) {
|
|
44
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
45
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
46
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
47
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/services/feature-flag.service.ts
|
|
51
|
+
var _FeatureFlagService;
|
|
52
|
+
let FeatureFlagService = _FeatureFlagService = class FeatureFlagService {
|
|
53
|
+
options;
|
|
54
|
+
env;
|
|
55
|
+
routerContext;
|
|
56
|
+
logger;
|
|
57
|
+
apps = /* @__PURE__ */ new Map();
|
|
58
|
+
bindingName;
|
|
59
|
+
binding;
|
|
60
|
+
manifest;
|
|
61
|
+
constructor(options, env, routerContext, logger, initialBinding) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.env = env;
|
|
64
|
+
this.routerContext = routerContext;
|
|
65
|
+
this.logger = logger;
|
|
66
|
+
for (const app of options.apps) this.apps.set(app.binding, app);
|
|
67
|
+
this.bindTo(initialBinding ?? options.default ?? options.apps[0]?.binding);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Switch to a different configured Flagship app.
|
|
71
|
+
*
|
|
72
|
+
* Returns a new immutable instance bound to `binding`; the original is
|
|
73
|
+
* unchanged. The binding must be declared in the module's `apps`.
|
|
74
|
+
*/
|
|
75
|
+
use(binding) {
|
|
76
|
+
if (binding === this.bindingName) return this;
|
|
77
|
+
return new _FeatureFlagService(this.options, this.env, this.routerContext, this.logger, binding);
|
|
78
|
+
}
|
|
79
|
+
/** The binding name this instance currently targets. */
|
|
80
|
+
get app() {
|
|
81
|
+
return this.bindingName;
|
|
82
|
+
}
|
|
83
|
+
/** Returns the raw flag value without type checking. */
|
|
84
|
+
async get(flagKey, defaultValue, context) {
|
|
85
|
+
const fallback = this.fallback(flagKey, defaultValue);
|
|
86
|
+
return this.safe(flagKey, async () => this.binding.get(flagKey, fallback, await this.context(context)), () => fallback);
|
|
87
|
+
}
|
|
88
|
+
/** Returns the flag value as a `boolean`. */
|
|
89
|
+
async getBooleanValue(flagKey, defaultValue, context) {
|
|
90
|
+
const fallback = this.fallback(flagKey, defaultValue, false);
|
|
91
|
+
return this.safe(flagKey, async () => this.binding.getBooleanValue(flagKey, fallback, await this.context(context)), () => fallback);
|
|
92
|
+
}
|
|
93
|
+
/** Returns the flag value as a `string`. */
|
|
94
|
+
async getStringValue(flagKey, defaultValue, context) {
|
|
95
|
+
const fallback = this.fallback(flagKey, defaultValue, "");
|
|
96
|
+
return this.safe(flagKey, async () => this.binding.getStringValue(flagKey, fallback, await this.context(context)), () => fallback);
|
|
97
|
+
}
|
|
98
|
+
/** Returns the flag value as a `number`. */
|
|
99
|
+
async getNumberValue(flagKey, defaultValue, context) {
|
|
100
|
+
const fallback = this.fallback(flagKey, defaultValue, 0);
|
|
101
|
+
return this.safe(flagKey, async () => this.binding.getNumberValue(flagKey, fallback, await this.context(context)), () => fallback);
|
|
102
|
+
}
|
|
103
|
+
/** Returns the flag value as a typed object. */
|
|
104
|
+
async getObjectValue(flagKey, defaultValue, context) {
|
|
105
|
+
const fallback = this.fallback(flagKey, defaultValue, {});
|
|
106
|
+
return this.safe(flagKey, async () => this.binding.getObjectValue(flagKey, fallback, await this.context(context)), () => fallback);
|
|
107
|
+
}
|
|
108
|
+
/** Returns the `boolean` flag value with evaluation metadata. */
|
|
109
|
+
async getBooleanDetails(flagKey, defaultValue, context) {
|
|
110
|
+
const fallback = this.fallback(flagKey, defaultValue, false);
|
|
111
|
+
return this.safe(flagKey, async () => this.binding.getBooleanDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error));
|
|
112
|
+
}
|
|
113
|
+
/** Returns the `string` flag value with evaluation metadata. */
|
|
114
|
+
async getStringDetails(flagKey, defaultValue, context) {
|
|
115
|
+
const fallback = this.fallback(flagKey, defaultValue, "");
|
|
116
|
+
return this.safe(flagKey, async () => this.binding.getStringDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error));
|
|
117
|
+
}
|
|
118
|
+
/** Returns the `number` flag value with evaluation metadata. */
|
|
119
|
+
async getNumberDetails(flagKey, defaultValue, context) {
|
|
120
|
+
const fallback = this.fallback(flagKey, defaultValue, 0);
|
|
121
|
+
return this.safe(flagKey, async () => this.binding.getNumberDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error));
|
|
122
|
+
}
|
|
123
|
+
/** Returns the typed object flag value with evaluation metadata. */
|
|
124
|
+
async getObjectDetails(flagKey, defaultValue, context) {
|
|
125
|
+
const fallback = this.fallback(flagKey, defaultValue, {});
|
|
126
|
+
return this.safe(flagKey, async () => this.binding.getObjectDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Evaluates every flag declared in the current app's manifest and returns a
|
|
130
|
+
* `{ key: value }` map. The evaluation method is chosen from each declared
|
|
131
|
+
* default's type. Powers `FeatureFlagShareMiddleware`.
|
|
132
|
+
*/
|
|
133
|
+
async all(context) {
|
|
134
|
+
const keys = Object.keys(this.manifest);
|
|
135
|
+
let merged;
|
|
136
|
+
try {
|
|
137
|
+
merged = await this.context(context);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.logger?.warn(`Feature flag context resolution failed on app "${this.bindingName}"; returning manifest defaults.`, { error: this.message(error) });
|
|
140
|
+
return { ...this.manifest };
|
|
141
|
+
}
|
|
142
|
+
const values = await Promise.all(keys.map((key) => this.evaluate(key, this.manifest[key], merged)));
|
|
143
|
+
const result = {};
|
|
144
|
+
keys.forEach((key, i) => {
|
|
145
|
+
result[key] = values[i];
|
|
146
|
+
});
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
bindTo(name) {
|
|
150
|
+
if (!name) throw new FeatureFlagError("No feature flag apps configured. Provide at least one app in FeatureFlagModule.forRoot({ apps: [...] }).");
|
|
151
|
+
const app = this.apps.get(name);
|
|
152
|
+
if (!app) throw new FeatureFlagError(`Feature flag app "${name}" is not configured.`);
|
|
153
|
+
const binding = this.env[name];
|
|
154
|
+
if (!binding) throw new FeatureFlagError(`Flagship binding "${name}" was not found in the environment.`);
|
|
155
|
+
this.bindingName = name;
|
|
156
|
+
this.binding = binding;
|
|
157
|
+
this.manifest = app.flags ?? {};
|
|
158
|
+
}
|
|
159
|
+
/** Resolves the merged evaluation context (default context + per-call override). */
|
|
160
|
+
async context(callContext) {
|
|
161
|
+
if (!this.options.context || !this.routerContext) return callContext;
|
|
162
|
+
const base = await this.options.context(this.routerContext);
|
|
163
|
+
return callContext ? {
|
|
164
|
+
...base,
|
|
165
|
+
...callContext
|
|
166
|
+
} : base;
|
|
167
|
+
}
|
|
168
|
+
/** Picks the default: explicit arg, then manifest, then the type's zero value. */
|
|
169
|
+
fallback(flagKey, provided, zero) {
|
|
170
|
+
if (provided !== void 0) return provided;
|
|
171
|
+
if (flagKey in this.manifest) return this.manifest[flagKey];
|
|
172
|
+
return zero;
|
|
173
|
+
}
|
|
174
|
+
/** Evaluates a single flag, choosing the method from the declared default's type. */
|
|
175
|
+
evaluate(flagKey, declared, context) {
|
|
176
|
+
switch (typeof declared) {
|
|
177
|
+
case "boolean": return this.safe(flagKey, () => this.binding.getBooleanValue(flagKey, declared, context), () => declared);
|
|
178
|
+
case "number": return this.safe(flagKey, () => this.binding.getNumberValue(flagKey, declared, context), () => declared);
|
|
179
|
+
case "string": return this.safe(flagKey, () => this.binding.getStringValue(flagKey, declared, context), () => declared);
|
|
180
|
+
default: return this.safe(flagKey, () => this.binding.getObjectValue(flagKey, declared, context), () => declared);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Runs an evaluation and absorbs any failure into the fallback. The binding
|
|
185
|
+
* already returns the default on evaluation errors, but the call itself can
|
|
186
|
+
* still reject — e.g. when a `remote: true` binding's dev-proxy WebSocket
|
|
187
|
+
* tunnel drops. A flag lookup must never take the request down with it.
|
|
188
|
+
*/
|
|
189
|
+
async safe(flagKey, evaluate, onError) {
|
|
190
|
+
try {
|
|
191
|
+
return await evaluate();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.logger?.warn(`Feature flag evaluation failed for "${flagKey}" on app "${this.bindingName}"; returning the fallback value.`, { error: this.message(error) });
|
|
194
|
+
return onError(error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Synthesizes the details shape the binding would return for a failed evaluation. */
|
|
198
|
+
errorDetails(flagKey, value, error) {
|
|
199
|
+
return {
|
|
200
|
+
flagKey,
|
|
201
|
+
value,
|
|
202
|
+
reason: "ERROR",
|
|
203
|
+
errorMessage: this.message(error)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Extracts a human-readable message from an unknown thrown value. */
|
|
207
|
+
message(error) {
|
|
208
|
+
return error instanceof Error ? error.message : String(error);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
FeatureFlagService = _FeatureFlagService = __decorate([
|
|
212
|
+
Transient(FEATURE_FLAG_TOKENS.FeatureFlagService),
|
|
213
|
+
__decorateParam(0, inject(FEATURE_FLAG_TOKENS.Options)),
|
|
214
|
+
__decorateParam(1, inject(DI_TOKENS.CloudflareEnv)),
|
|
215
|
+
__decorateParam(2, inject(ROUTER_TOKENS.RouterContext, { isOptional: true })),
|
|
216
|
+
__decorateParam(3, inject(LOGGER_TOKENS.LoggerService, { isOptional: true })),
|
|
217
|
+
__decorateMetadata("design:paramtypes", [
|
|
218
|
+
Object,
|
|
219
|
+
Object,
|
|
220
|
+
Object,
|
|
221
|
+
Object,
|
|
222
|
+
String
|
|
223
|
+
])
|
|
224
|
+
], FeatureFlagService);
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/feature-flags.module.ts
|
|
227
|
+
var _FeatureFlagModule;
|
|
228
|
+
let FeatureFlagModule = _FeatureFlagModule = class FeatureFlagModule {
|
|
229
|
+
/** Configure with static options. */
|
|
230
|
+
static forRoot(options) {
|
|
231
|
+
return {
|
|
232
|
+
module: _FeatureFlagModule,
|
|
233
|
+
providers: [{
|
|
234
|
+
provide: FEATURE_FLAG_TOKENS.Options,
|
|
235
|
+
useValue: options
|
|
236
|
+
}]
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Configure with an async factory (when options depend on other services). */
|
|
240
|
+
static forRootAsync(options) {
|
|
241
|
+
return {
|
|
242
|
+
module: _FeatureFlagModule,
|
|
243
|
+
providers: [{
|
|
244
|
+
provide: FEATURE_FLAG_TOKENS.Options,
|
|
245
|
+
useFactory: options.useFactory,
|
|
246
|
+
inject: options.inject
|
|
247
|
+
}]
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
FeatureFlagModule = _FeatureFlagModule = __decorate([Module({ providers: [{
|
|
252
|
+
provide: FEATURE_FLAG_TOKENS.FeatureFlagService,
|
|
253
|
+
useClass: FeatureFlagService
|
|
254
|
+
}] })], FeatureFlagModule);
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/feature-flag-share.middleware.ts
|
|
257
|
+
let FeatureFlagShareMiddleware = class FeatureFlagShareMiddleware {
|
|
258
|
+
flags;
|
|
259
|
+
constructor(flags) {
|
|
260
|
+
this.flags = flags;
|
|
261
|
+
}
|
|
262
|
+
async handle(ctx, next) {
|
|
263
|
+
if (ctx.c.req.method === "GET" && typeof ctx.share === "function") ctx.share("featureFlags", await this.flags.all());
|
|
264
|
+
await next();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
FeatureFlagShareMiddleware = __decorate([
|
|
268
|
+
Transient(),
|
|
269
|
+
__decorateParam(0, inject(FEATURE_FLAG_TOKENS.FeatureFlagService)),
|
|
270
|
+
__decorateMetadata("design:paramtypes", [Object])
|
|
271
|
+
], FeatureFlagShareMiddleware);
|
|
272
|
+
//#endregion
|
|
273
|
+
export { FEATURE_FLAG_TOKENS, FeatureFlagError, FeatureFlagModule, FeatureFlagService, FeatureFlagShareMiddleware };
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/feature-flags.tokens.ts","../src/feature-flags.error.ts","../src/services/feature-flag.service.ts","../src/feature-flags.module.ts","../src/feature-flag-share.middleware.ts"],"sourcesContent":["/**\n * DI tokens for the feature-flags module.\n *\n * Use `Symbol.for(...)` so the tokens resolve to the same symbol across module\n * boundaries (the global symbol registry).\n */\nexport const FEATURE_FLAG_TOKENS = {\n /** The resolved {@link FeatureFlagModuleOptions}. */\n Options: Symbol.for('stratal:feature-flags:options'),\n /** The request-scoped {@link FeatureFlagService} bound to the default app. */\n FeatureFlagService: Symbol.for('stratal:feature-flags:service'),\n} as const\n\nexport type FeatureFlagToken = (typeof FEATURE_FLAG_TOKENS)[keyof typeof FEATURE_FLAG_TOKENS]\n","import { ApplicationError } from 'stratal/errors'\n\n/**\n * Thrown for feature-flag misconfiguration — an unknown app or a Flagship\n * binding that is not present on the Worker environment.\n *\n * Note: flag *evaluation* never throws; the binding returns the supplied\n * default value on error.\n */\nexport class FeatureFlagError extends ApplicationError {}\n","import type { StratalEnv } from 'stratal'\nimport { DI_TOKENS, Transient, inject } from 'stratal/di'\nimport { LOGGER_TOKENS, type LoggerService } from 'stratal/logger'\nimport { ROUTER_TOKENS, type RouterContext } from 'stratal/router'\nimport { FeatureFlagError } from '../feature-flags.error'\nimport { FEATURE_FLAG_TOKENS } from '../feature-flags.tokens'\nimport type {\n FeatureFlagApp,\n FeatureFlagModuleOptions,\n FlagManifest,\n FlagValue,\n FlagshipBindingName,\n} from '../types'\n\n/**\n * Feature Flag Service\n *\n * Type-safe wrapper around a Cloudflare Flagship binding (`env.FLAGS`). Mirrors\n * the binding's evaluation methods 1:1, with two ergonomic additions:\n *\n * - **Manifest defaults** — when you omit a default, the value declared in the\n * app's `flags` manifest is used (an explicit argument always wins).\n * - **Default context** — the module's `context` resolver is merged into every\n * evaluation (per-call context overrides it). Resolved from the current\n * request; skipped automatically outside request scope.\n *\n * Switch to another Flagship app with {@link use}. Evaluation never throws —\n * the binding returns the default value on evaluation errors, and the service\n * catches everything else (e.g. a dropped remote-binding tunnel in local dev)\n * and returns the same fallback, logging a warning.\n *\n * @example\n * ```typescript\n * @inject(FEATURE_FLAG_TOKENS.FeatureFlagService)\n * private readonly flags: FeatureFlagService\n *\n * const enabled = await this.flags.getBooleanValue('new-checkout') // manifest default\n * const layout = await this.flags.use('EXPERIMENT_FLAGS').getStringValue('layout', 'v1')\n * ```\n *\n * @see https://developers.cloudflare.com/flagship/binding/\n */\n@Transient(FEATURE_FLAG_TOKENS.FeatureFlagService)\nexport class FeatureFlagService {\n private readonly apps = new Map<string, FeatureFlagApp>()\n private bindingName!: string\n private binding!: Flagship\n private manifest!: FlagManifest\n\n constructor(\n @inject(FEATURE_FLAG_TOKENS.Options) private readonly options: FeatureFlagModuleOptions,\n @inject(DI_TOKENS.CloudflareEnv) private readonly env: StratalEnv,\n @inject(ROUTER_TOKENS.RouterContext, { isOptional: true }) private readonly routerContext: RouterContext | undefined,\n @inject(LOGGER_TOKENS.LoggerService, { isOptional: true }) private readonly logger: LoggerService | undefined,\n // Only passed by `use()`; DI never injects it. Lets `use()` bind exactly once\n // instead of binding to the default in the constructor and re-binding after.\n initialBinding?: string,\n ) {\n for (const app of options.apps) {\n this.apps.set(app.binding, app)\n }\n this.bindTo(initialBinding ?? options.default ?? options.apps[0]?.binding)\n }\n\n /**\n * Switch to a different configured Flagship app.\n *\n * Returns a new immutable instance bound to `binding`; the original is\n * unchanged. The binding must be declared in the module's `apps`.\n */\n use(binding: FlagshipBindingName): FeatureFlagService {\n if (binding === this.bindingName) return this\n return new FeatureFlagService(this.options, this.env, this.routerContext, this.logger, binding)\n }\n\n /** The binding name this instance currently targets. */\n get app(): string {\n return this.bindingName\n }\n\n // ==================== EVALUATION ====================\n\n /** Returns the raw flag value without type checking. */\n async get(flagKey: string, defaultValue?: unknown, context?: FlagshipEvaluationContext): Promise<unknown> {\n const fallback = this.fallback(flagKey, defaultValue)\n return this.safe(flagKey, async () => this.binding.get(flagKey, fallback, await this.context(context)), () => fallback)\n }\n\n /** Returns the flag value as a `boolean`. */\n async getBooleanValue(flagKey: string, defaultValue?: boolean, context?: FlagshipEvaluationContext): Promise<boolean> {\n const fallback = this.fallback(flagKey, defaultValue, false)\n return this.safe(flagKey, async () => this.binding.getBooleanValue(flagKey, fallback, await this.context(context)), () => fallback)\n }\n\n /** Returns the flag value as a `string`. */\n async getStringValue(flagKey: string, defaultValue?: string, context?: FlagshipEvaluationContext): Promise<string> {\n const fallback = this.fallback(flagKey, defaultValue, '')\n return this.safe(flagKey, async () => this.binding.getStringValue(flagKey, fallback, await this.context(context)), () => fallback)\n }\n\n /** Returns the flag value as a `number`. */\n async getNumberValue(flagKey: string, defaultValue?: number, context?: FlagshipEvaluationContext): Promise<number> {\n const fallback = this.fallback(flagKey, defaultValue, 0)\n return this.safe(flagKey, async () => this.binding.getNumberValue(flagKey, fallback, await this.context(context)), () => fallback)\n }\n\n /** Returns the flag value as a typed object. */\n async getObjectValue<T extends object>(flagKey: string, defaultValue?: T, context?: FlagshipEvaluationContext): Promise<T> {\n const fallback = this.fallback(flagKey, defaultValue, {} as T)\n return this.safe(flagKey, async () => this.binding.getObjectValue<T>(flagKey, fallback, await this.context(context)), () => fallback)\n }\n\n /** Returns the `boolean` flag value with evaluation metadata. */\n async getBooleanDetails(flagKey: string, defaultValue?: boolean, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<boolean>> {\n const fallback = this.fallback(flagKey, defaultValue, false)\n return this.safe(flagKey, async () => this.binding.getBooleanDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error))\n }\n\n /** Returns the `string` flag value with evaluation metadata. */\n async getStringDetails(flagKey: string, defaultValue?: string, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<string>> {\n const fallback = this.fallback(flagKey, defaultValue, '')\n return this.safe(flagKey, async () => this.binding.getStringDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error))\n }\n\n /** Returns the `number` flag value with evaluation metadata. */\n async getNumberDetails(flagKey: string, defaultValue?: number, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<number>> {\n const fallback = this.fallback(flagKey, defaultValue, 0)\n return this.safe(flagKey, async () => this.binding.getNumberDetails(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error))\n }\n\n /** Returns the typed object flag value with evaluation metadata. */\n async getObjectDetails<T extends object>(flagKey: string, defaultValue?: T, context?: FlagshipEvaluationContext): Promise<FlagshipEvaluationDetails<T>> {\n const fallback = this.fallback(flagKey, defaultValue, {} as T)\n return this.safe(flagKey, async () => this.binding.getObjectDetails<T>(flagKey, fallback, await this.context(context)), (error) => this.errorDetails(flagKey, fallback, error))\n }\n\n /**\n * Evaluates every flag declared in the current app's manifest and returns a\n * `{ key: value }` map. The evaluation method is chosen from each declared\n * default's type. Powers `FeatureFlagShareMiddleware`.\n */\n async all(context?: FlagshipEvaluationContext): Promise<Record<string, FlagValue>> {\n const keys = Object.keys(this.manifest)\n // Resolve the shared context once. A throwing resolver must not take the\n // batch down — fall back to the manifest defaults, the same values each\n // per-flag method returns when evaluation can't proceed.\n let merged: FlagshipEvaluationContext | undefined\n try {\n merged = await this.context(context)\n } catch (error) {\n this.logger?.warn(`Feature flag context resolution failed on app \"${this.bindingName}\"; returning manifest defaults.`, { error: this.message(error) })\n return { ...this.manifest }\n }\n const values = await Promise.all(keys.map((key) => this.evaluate(key, this.manifest[key], merged)))\n const result: Record<string, FlagValue> = {}\n keys.forEach((key, i) => {\n result[key] = values[i]\n })\n return result\n }\n\n // ==================== INTERNAL ====================\n\n private bindTo(name: string | undefined): void {\n if (!name) {\n throw new FeatureFlagError('No feature flag apps configured. Provide at least one app in FeatureFlagModule.forRoot({ apps: [...] }).')\n }\n const app = this.apps.get(name)\n if (!app) {\n throw new FeatureFlagError(`Feature flag app \"${name}\" is not configured.`)\n }\n const binding = (this.env as unknown as Record<string, unknown>)[name] as Flagship | undefined\n if (!binding) {\n throw new FeatureFlagError(`Flagship binding \"${name}\" was not found in the environment.`)\n }\n this.bindingName = name\n this.binding = binding\n this.manifest = app.flags ?? {}\n }\n\n /** Resolves the merged evaluation context (default context + per-call override). */\n private async context(callContext?: FlagshipEvaluationContext): Promise<FlagshipEvaluationContext | undefined> {\n if (!this.options.context || !this.routerContext) return callContext\n const base = await this.options.context(this.routerContext)\n return callContext ? { ...base, ...callContext } : base\n }\n\n /** Picks the default: explicit arg, then manifest, then the type's zero value. */\n private fallback<T>(flagKey: string, provided: T | undefined, zero?: T): T {\n if (provided !== undefined) return provided\n if (flagKey in this.manifest) return this.manifest[flagKey] as T\n return zero as T\n }\n\n /** Evaluates a single flag, choosing the method from the declared default's type. */\n private evaluate(flagKey: string, declared: FlagValue, context?: FlagshipEvaluationContext): Promise<FlagValue> {\n switch (typeof declared) {\n case 'boolean':\n return this.safe(flagKey, () => this.binding.getBooleanValue(flagKey, declared, context), () => declared)\n case 'number':\n return this.safe(flagKey, () => this.binding.getNumberValue(flagKey, declared, context), () => declared)\n case 'string':\n return this.safe(flagKey, () => this.binding.getStringValue(flagKey, declared, context), () => declared)\n default:\n return this.safe(flagKey, () => this.binding.getObjectValue(flagKey, declared, context), () => declared)\n }\n }\n\n /**\n * Runs an evaluation and absorbs any failure into the fallback. The binding\n * already returns the default on evaluation errors, but the call itself can\n * still reject — e.g. when a `remote: true` binding's dev-proxy WebSocket\n * tunnel drops. A flag lookup must never take the request down with it.\n */\n private async safe<T>(flagKey: string, evaluate: () => Promise<T>, onError: (error: unknown) => T): Promise<T> {\n try {\n return await evaluate()\n } catch (error) {\n this.logger?.warn(`Feature flag evaluation failed for \"${flagKey}\" on app \"${this.bindingName}\"; returning the fallback value.`, {\n error: this.message(error),\n })\n return onError(error)\n }\n }\n\n /** Synthesizes the details shape the binding would return for a failed evaluation. */\n private errorDetails<T>(flagKey: string, value: T, error: unknown): FlagshipEvaluationDetails<T> {\n return { flagKey, value, reason: 'ERROR', errorMessage: this.message(error) }\n }\n\n /** Extracts a human-readable message from an unknown thrown value. */\n private message(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n }\n}\n","import { Module } from 'stratal/module'\nimport type { AsyncModuleOptions, DynamicModule } from 'stratal/module'\nimport { FEATURE_FLAG_TOKENS } from './feature-flags.tokens'\nimport { FeatureFlagService } from './services/feature-flag.service'\nimport type { FeatureFlagModuleOptions } from './types'\n\n/**\n * Feature Flag Module\n *\n * Evaluates Cloudflare Flagship feature flags through the native Worker binding.\n * Declare your apps (and the flags you use) once; inject {@link FeatureFlagService}\n * to evaluate them.\n *\n * To expose flags to an Inertia frontend, register {@link FeatureFlagShareMiddleware}\n * yourself — scope it to the controllers that render pages (`router.middleware(...)`)\n * or app-wide (`router.use(...)`) from a module's `configureRoutes`. It is not\n * registered for you, so a stalled Flagship binding never blocks unrelated routes.\n *\n * @example\n * ```typescript\n * FeatureFlagModule.forRoot({\n * apps: [{ binding: 'FLAGS', flags: { 'new-checkout': false } }],\n * context: (ctx) => ({ userId: ctx.user().id }), // ctx.user() from @stratal/framework\n * })\n *\n * // Or async, from config namespaces:\n * FeatureFlagModule.forRootAsync({\n * inject: [flagsConfig.KEY],\n * useFactory: (cfg) => ({ apps: cfg.apps, default: cfg.default }),\n * })\n * ```\n */\n@Module({\n providers: [\n { provide: FEATURE_FLAG_TOKENS.FeatureFlagService, useClass: FeatureFlagService },\n ],\n})\nexport class FeatureFlagModule {\n /** Configure with static options. */\n static forRoot(options: FeatureFlagModuleOptions): DynamicModule {\n return {\n module: FeatureFlagModule,\n providers: [\n { provide: FEATURE_FLAG_TOKENS.Options, useValue: options },\n ],\n }\n }\n\n /** Configure with an async factory (when options depend on other services). */\n static forRootAsync(options: AsyncModuleOptions<FeatureFlagModuleOptions>): DynamicModule {\n return {\n module: FeatureFlagModule,\n providers: [\n {\n provide: FEATURE_FLAG_TOKENS.Options,\n useFactory: options.useFactory,\n inject: options.inject,\n },\n ],\n }\n }\n}\n","/// <reference types=\"@stratal/inertia\" />\n\nimport { Transient, inject } from 'stratal/di';\nimport type { Middleware, Next, RouterContext } from 'stratal/router';\nimport { FEATURE_FLAG_TOKENS } from './feature-flags.tokens';\nimport type { FeatureFlagService } from './services/feature-flag.service';\n\n/**\n * Evaluates the declared flag manifest for the default app and shares it as the\n * `featureFlags` prop on every Inertia page rendered during the request.\n *\n * Only runs on `GET` requests — page renders (full visits and partial reloads)\n * are always `GET`, so mutating API calls don't trigger evaluation. No-ops when\n * Inertia is not installed (`ctx.share` absent).\n *\n * Register it yourself, scoped to where flags are actually needed — a Flagship\n * stall then only affects those routes, not the whole app:\n *\n * ```typescript\n * configureRoutes(router: Router): void {\n * // only the controllers that render flag-aware pages\n * router.group([DashboardController], (r) => r.middleware(FeatureFlagShareMiddleware))\n * // ...or app-wide: router.use(FeatureFlagShareMiddleware)\n * }\n * ```\n */\n@Transient()\nexport class FeatureFlagShareMiddleware implements Middleware {\n constructor(\n @inject(FEATURE_FLAG_TOKENS.FeatureFlagService) private readonly flags: FeatureFlagService,\n ) {}\n\n async handle(ctx: RouterContext, next: Next): Promise<void> {\n if (ctx.c.req.method === 'GET' && typeof ctx.share === 'function') {\n ctx.share('featureFlags', await this.flags.all())\n }\n await next()\n }\n}\n"],"mappings":"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"}
|
package/dist/react.d.mts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { r as FeatureFlagRegistry } from "./types-D-uV0zlC.mjs";
|
|
2
|
+
//#region src/react/use-feature-flags.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Returns the full map of feature flags shared by `FeatureFlagShareMiddleware`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useFeatureFlags(): Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Returns a single shared feature flag value.
|
|
9
|
+
*
|
|
10
|
+
* When you augment {@link FeatureFlagRegistry}, the key and return type are
|
|
11
|
+
* checked against your declared flags. Otherwise pass an explicit default.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const showNewCheckout = useFlag('new-checkout') // typed via FeatureFlagRegistry
|
|
16
|
+
* const layout = useFlag('layout', 'v1') // loose fallback
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function useFlag<K extends keyof FeatureFlagRegistry>(key: K): FeatureFlagRegistry[K];
|
|
20
|
+
export declare function useFlag<T>(key: string, defaultValue: T): T;
|
|
21
|
+
//#endregion
|
|
22
|
+
//# sourceMappingURL=react.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.mts","names":[],"sources":["../src/react/use-feature-flags.ts"],"mappings":";;;;;wBAWgB,mBAAmB;;;;;;;;;;;;;wBAgBnB,QAAQ,gBAAgB,qBAAqB,KAAK,IAAI,oBAAoB;wBAC1E,QAAQ,GAAG,aAAa,cAAc,IAAI"}
|
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { usePage } from "@inertiajs/react";
|
|
2
|
+
//#region src/react/use-feature-flags.ts
|
|
3
|
+
/**
|
|
4
|
+
* Returns the full map of feature flags shared by `FeatureFlagShareMiddleware`.
|
|
5
|
+
*/
|
|
6
|
+
function useFeatureFlags() {
|
|
7
|
+
return usePage().props.featureFlags ?? {};
|
|
8
|
+
}
|
|
9
|
+
function useFlag(key, defaultValue) {
|
|
10
|
+
const flags = useFeatureFlags();
|
|
11
|
+
return key in flags ? flags[key] : defaultValue;
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
export { useFeatureFlags, useFlag };
|
|
15
|
+
|
|
16
|
+
//# sourceMappingURL=react.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.mjs","names":[],"sources":["../src/react/use-feature-flags.ts"],"sourcesContent":["import type { PageProps } from '@inertiajs/core'\nimport { usePage } from '@inertiajs/react'\nimport type { FeatureFlagRegistry } from '../types'\n\ninterface FeatureFlagsPageProps extends PageProps {\n featureFlags?: Record<string, unknown>\n}\n\n/**\n * Returns the full map of feature flags shared by `FeatureFlagShareMiddleware`.\n */\nexport function useFeatureFlags(): Record<string, unknown> {\n return usePage<FeatureFlagsPageProps>().props.featureFlags ?? {}\n}\n\n/**\n * Returns a single shared feature flag value.\n *\n * When you augment {@link FeatureFlagRegistry}, the key and return type are\n * checked against your declared flags. Otherwise pass an explicit default.\n *\n * @example\n * ```tsx\n * const showNewCheckout = useFlag('new-checkout') // typed via FeatureFlagRegistry\n * const layout = useFlag('layout', 'v1') // loose fallback\n * ```\n */\nexport function useFlag<K extends keyof FeatureFlagRegistry>(key: K): FeatureFlagRegistry[K]\nexport function useFlag<T>(key: string, defaultValue: T): T\nexport function useFlag(key: string, defaultValue?: unknown): unknown {\n const flags = useFeatureFlags()\n return key in flags ? flags[key] : defaultValue\n}\n"],"mappings":";;;;;AAWA,SAAgB,kBAA2C;CACzD,OAAO,QAA+B,CAAC,CAAC,MAAM,gBAAgB,CAAC;AACjE;AAgBA,SAAgB,QAAQ,KAAa,cAAiC;CACpE,MAAM,QAAQ,gBAAgB;CAC9B,OAAO,OAAO,QAAQ,MAAM,OAAO;AACrC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RouterContext } from "stratal/router";
|
|
2
|
+
import { StratalEnv } from "stratal";
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A value a feature flag can resolve to.
|
|
6
|
+
*
|
|
7
|
+
* @see https://developers.cloudflare.com/flagship/binding/methods/
|
|
8
|
+
*/
|
|
9
|
+
type FlagValue = boolean | string | number | object;
|
|
10
|
+
/**
|
|
11
|
+
* String keys of the augmented `StratalEnv` whose value is a Flagship binding.
|
|
12
|
+
*/
|
|
13
|
+
type FlagshipBindingFromEnv = Extract<{ [K in keyof StratalEnv]: StratalEnv[K] extends Flagship ? K : never; }[keyof StratalEnv], string>;
|
|
14
|
+
/**
|
|
15
|
+
* Type-safe Flagship binding name.
|
|
16
|
+
*
|
|
17
|
+
* Resolves to the union of `Flagship`-typed binding keys on the augmented
|
|
18
|
+
* `StratalEnv`. Falls back to `string` when no Flagship bindings are visible
|
|
19
|
+
* (for example library code compiled outside an app's env context).
|
|
20
|
+
*/
|
|
21
|
+
type FlagshipBindingName = [FlagshipBindingFromEnv] extends [never] ? string : FlagshipBindingFromEnv;
|
|
22
|
+
/**
|
|
23
|
+
* Augment this interface to get typed flag keys for `useFlag()` and the service.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* declare module '@stratal/feature-flags' {
|
|
28
|
+
* interface FeatureFlagRegistry {
|
|
29
|
+
* 'new-checkout': boolean
|
|
30
|
+
* 'checkout-flow': string
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
interface FeatureFlagRegistry {}
|
|
36
|
+
/**
|
|
37
|
+
* A declared set of flags and their default values.
|
|
38
|
+
*
|
|
39
|
+
* Flagship has no enumeration API, so the flags you intend to evaluate (and
|
|
40
|
+
* share to the frontend) must be declared once here. The default also
|
|
41
|
+
* doubles as the type hint used to pick the evaluation method in `all()`.
|
|
42
|
+
*/
|
|
43
|
+
type FlagManifest = Record<string, FlagValue>;
|
|
44
|
+
/**
|
|
45
|
+
* A single Flagship app bound to the Worker.
|
|
46
|
+
*/
|
|
47
|
+
interface FeatureFlagApp {
|
|
48
|
+
/** Flagship binding name from your Wrangler config (type-checked against `StratalEnv`). */
|
|
49
|
+
binding: FlagshipBindingName;
|
|
50
|
+
/** Declared flags + defaults for this app. Used for manifest defaults and Inertia sharing. */
|
|
51
|
+
flags?: FlagManifest;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Feature-flags module configuration.
|
|
55
|
+
*/
|
|
56
|
+
interface FeatureFlagModuleOptions {
|
|
57
|
+
/** One or more Flagship apps. A Worker may bind to multiple apps. */
|
|
58
|
+
apps: FeatureFlagApp[];
|
|
59
|
+
/** Default app binding used by the injected `FeatureFlagService`. Defaults to `apps[0].binding`. */
|
|
60
|
+
default?: FlagshipBindingName;
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a per-request evaluation context (for example `{ userId }`) merged
|
|
63
|
+
* into every evaluation. Per-call context passed to a method overrides these.
|
|
64
|
+
* Receives the current request context; skipped outside request scope.
|
|
65
|
+
*/
|
|
66
|
+
context?: (ctx: RouterContext) => FlagshipEvaluationContext | Promise<FlagshipEvaluationContext>;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { FlagValue as a, FlagManifest as i, FeatureFlagModuleOptions as n, FlagshipBindingName as o, FeatureFlagRegistry as r, FeatureFlagApp as t };
|
|
70
|
+
//# sourceMappingURL=types-D-uV0zlC.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types-D-uV0zlC.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;;;;KAQY;;;;KAKP,yBAAyB,WACzB,WAAW,aAAa,WAAW,WAAW,WAAW,mBAAkB;;;;;;;;KAWpE,uBAAuB,mDAE/B;;;;;;;;;;;;;;UAea;;;;;;;;KASL,eAAe,eAAe;;;;UAKzB;;EAEf,SAAS;;EAET,QAAQ;;;;;UAMO;;EAEf,MAAM;;EAEN,UAAU;;;;;;EAMV,WAAW,KAAK,kBAAkB,4BAA4B,QAAQ"}
|
package/package.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stratal/feature-flags",
|
|
3
|
+
"version": "0.0.0-canary-e5681b8",
|
|
4
|
+
"description": "Cloudflare Flagship feature flags for the Stratal framework — binding API wrapper with Inertia.js sharing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Temitayo Fadojutimi",
|
|
9
|
+
"url": "https://x.com/adesege_"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://stratal.dev",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/strataljs/stratal.git",
|
|
15
|
+
"directory": "packages/feature-flags"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/strataljs/stratal/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"stratal",
|
|
22
|
+
"feature-flags",
|
|
23
|
+
"flagship",
|
|
24
|
+
"cloudflare-workers",
|
|
25
|
+
"inertia",
|
|
26
|
+
"react"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.0.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"types": "./dist/index.d.mts",
|
|
43
|
+
"import": "./dist/index.mjs"
|
|
44
|
+
},
|
|
45
|
+
"./react": {
|
|
46
|
+
"types": "./dist/react.d.mts",
|
|
47
|
+
"import": "./dist/react.mjs"
|
|
48
|
+
},
|
|
49
|
+
"./package.json": "./package.json"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"lint": "npx oxlint .",
|
|
57
|
+
"lint:fix": "npx oxlint --fix ."
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@inertiajs/core": ">=3",
|
|
61
|
+
"@inertiajs/react": ">=3",
|
|
62
|
+
"@stratal/inertia": "0.0.0-canary-e5681b8",
|
|
63
|
+
"hono": ">=4",
|
|
64
|
+
"react": ">=19",
|
|
65
|
+
"react-dom": ">=19",
|
|
66
|
+
"stratal": "0.0.0-canary-e5681b8"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@inertiajs/core": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"@inertiajs/react": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"@stratal/inertia": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"react": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"react-dom": {
|
|
82
|
+
"optional": true
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"devDependencies": {
|
|
86
|
+
"@cloudflare/workers-types": "5.20260919.1",
|
|
87
|
+
"@inertiajs/core": "^3.7.1",
|
|
88
|
+
"@inertiajs/react": "^3.7.1",
|
|
89
|
+
"@stratal/inertia": "0.0.0-canary-e5681b8",
|
|
90
|
+
"@types/node": "^26.6.2",
|
|
91
|
+
"@types/react": "^19.3.0",
|
|
92
|
+
"@types/react-dom": "^19.3.0",
|
|
93
|
+
"hono": "^4.13.8",
|
|
94
|
+
"react": "^19.3.0",
|
|
95
|
+
"react-dom": "^19.3.0",
|
|
96
|
+
"stratal": "0.0.0-canary-e5681b8",
|
|
97
|
+
"tsdown": "^0.23.0",
|
|
98
|
+
"typescript": "^7.0.2",
|
|
99
|
+
"vitest": "~4.1.11"
|
|
100
|
+
}
|
|
101
|
+
}
|