From f2eeda7fb8b4b8aa317717521a3be09bee3d8d47 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:40:55 -0700 Subject: [PATCH 1/2] Compile augmented npm packages from scriptc-pm source overlays (--sources) `scriptc build|run|coverage --sources[=]` reads a scriptc-pm schema-1 manifest (default: the nearest .scriptc-pm/manifest.json above the entry) and compiles each augmented package instance from its TypeScript overlay instead of its published JS. - Instances are keyed by realpath(installedDir). The resolver redirects an import that lands in an instance's installed directory to the instance's exports inside its overlay (resolveSourceOverlayModule); overlay files compile as program modules. - tsgo gets the pilot's "paths" injection, but only for names with a single augmented instance; other instances start dropped, and a second installed copy of a redirected name drops it too. - Fallback per instance through the drop-and-reload loop: a preflight diagnostic in an overlay, a program type error naming it, a top-level require() edge to it, a lowering refusal inside it, or a program that only fails against the source surface (probe) drops the overlay and the package compiles from published JS. Reasons are reported on stderr and as "source"/"source-fallback" rows in coverage. - Build caches key by the manifest's lockDigest and bytes instead of being bypassed; the network-fetching --provenance-sources pilot stays uncacheable and becomes one more producer of the same overlay set. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + packages/cli/src/bootstrap.ts | 2 + packages/compiler/src/cli/command.ts | 51 +- packages/compiler/src/cli/usage.ts | 24 + packages/compiler/src/coverage/report.ts | 69 +- .../compiler/src/coverage/verdict-cache.ts | 8 +- .../src/executable/executable-cache.ts | 5 + packages/compiler/src/frontend/analysis.ts | 9 +- .../src/frontend/lowering/lower-stmts.ts | 4 +- .../compiler/src/frontend/lowering/lowerer.ts | 4 +- packages/compiler/src/frontend/pipeline.ts | 326 ++++- packages/compiler/src/frontend/program.ts | 21 +- .../compiler/src/frontend/provenance-core.ts | 1 + .../src/frontend/provenance-registry.ts | 98 +- packages/compiler/src/frontend/resolve.ts | 118 +- .../compiler/src/frontend/source-overlays.ts | 380 ++++++ packages/compiler/src/index.ts | 19 +- .../compiler/src/library/library-cache.ts | 3 + packages/compiler/src/native/compiler.test.ts | 2 + packages/compiler/src/native/prepare.ts | 10 +- .../fixtures/source-overlays/project/emit.ts | 20 + .../fixtures/source-overlays/project/flags.ts | 17 + .../node_modules/@openfeature/core/LICENSE | 201 +++ .../@openfeature/core/dist/esm/index.js | 1166 +++++++++++++++++ .../@openfeature/core/dist/esm/package.json | 3 + .../@openfeature/core/dist/types.d.ts | 1076 +++++++++++++++ .../@openfeature/core/package.json | 49 + .../node_modules/@openfeature/core | 1 + .../@openfeature/server-sdk/LICENSE | 201 +++ .../@openfeature/server-sdk/dist/esm/index.js | 634 +++++++++ .../server-sdk/dist/esm/package.json | 3 + .../@openfeature/server-sdk/dist/types.d.ts | 420 ++++++ .../@openfeature/server-sdk/package.json | 56 + .../mitt@3.0.1/node_modules/mitt/LICENSE | 21 + .../mitt@3.0.1/node_modules/mitt/dist/mitt.js | 2 + .../node_modules/mitt/dist/mitt.mjs | 2 + .../node_modules/mitt/dist/mitt.umd.js | 2 + .../mitt@3.0.1/node_modules/mitt/index.d.ts | 21 + .../mitt@3.0.1/node_modules/mitt/package.json | 85 ++ .../node_modules/path-to-regexp/LICENSE | 21 + .../path-to-regexp/dist.es2015/index.js | 415 ++++++ .../path-to-regexp/dist/index.d.ts | 127 ++ .../node_modules/path-to-regexp/dist/index.js | 425 ++++++ .../node_modules/path-to-regexp/package.json | 63 + .../project/node_modules/@openfeature/core | 1 + .../node_modules/@openfeature/server-sdk | 1 + .../source-overlays/project/node_modules/mitt | 1 + .../project/node_modules/path-to-regexp | 1 + .../source-overlays/project/package.json | 1 + .../source-overlays/project/require.cts | 3 + .../source-overlays/project/routes.ts | 18 + .../source-overlays/sources/core/LICENSE | 201 +++ .../sources/core/src/client/client.ts | 11 + .../sources/core/src/client/index.ts | 1 + .../core/src/errors/flag-not-found-error.ts | 13 + .../sources/core/src/errors/general-error.ts | 13 + .../sources/core/src/errors/index.ts | 45 + .../core/src/errors/invalid-context-error.ts | 13 + .../src/errors/open-feature-error-abstract.ts | 20 + .../sources/core/src/errors/parse-error.ts | 13 + .../core/src/errors/provider-fatal-error.ts | 13 + .../src/errors/provider-not-ready-error.ts | 13 + .../src/errors/targeting-key-missing-error.ts | 13 + .../core/src/errors/type-mismatch-error.ts | 13 + .../sources/core/src/evaluation/context.ts | 36 + .../sources/core/src/evaluation/evaluation.ts | 122 ++ .../sources/core/src/evaluation/index.ts | 2 + .../sources/core/src/events/event-utils.ts | 27 + .../sources/core/src/events/eventing.ts | 113 ++ .../sources/core/src/events/events.ts | 73 ++ .../core/src/events/generic-event-emitter.ts | 110 ++ .../sources/core/src/events/index.ts | 5 + .../core/src/events/provider-event-emitter.ts | 23 + .../sources/core/src/filter.ts | 9 + .../core/src/hooks/evaluation-lifecycle.ts | 28 + .../sources/core/src/hooks/hook-data.ts | 72 + .../sources/core/src/hooks/hook.ts | 49 + .../sources/core/src/hooks/hooks.ts | 25 + .../sources/core/src/hooks/index.ts | 4 + .../source-overlays/sources/core/src/index.ts | 12 + .../sources/core/src/logger/default-logger.ts | 17 + .../sources/core/src/logger/index.ts | 3 + .../sources/core/src/logger/logger.ts | 22 + .../sources/core/src/logger/safe-logger.ts | 48 + .../sources/core/src/open-feature.ts | 443 +++++++ .../sources/core/src/provider/index.ts | 5 + .../src/provider/multi-provider/errors.ts | 57 + .../provider/multi-provider/status-tracker.ts | 113 ++ .../strategies/base-evaluation-strategy.ts | 152 +++ .../strategies/comparison-strategy.ts | 66 + .../strategies/first-match-strategy.ts | 36 + .../strategies/first-successful-strategy.ts | 32 + .../multi-provider/strategies/index.ts | 4 + .../core/src/provider/multi-provider/types.ts | 6 + .../sources/core/src/provider/provider.ts | 137 ++ .../sources/core/src/telemetry/attributes.ts | 88 ++ .../core/src/telemetry/evaluation-event.ts | 87 ++ .../core/src/telemetry/flag-metadata.ts | 20 + .../sources/core/src/telemetry/index.ts | 3 + .../sources/core/src/tracking/index.ts | 1 + .../core/src/tracking/tracking-event.ts | 13 + .../sources/core/src/type-guards.ts | 35 + .../sources/core/src/types/index.ts | 3 + .../sources/core/src/types/metadata.ts | 1 + .../sources/core/src/types/paradigm.ts | 4 + .../sources/core/src/types/structure.ts | 7 + .../source-overlays/sources/mitt/LICENSE | 21 + .../source-overlays/sources/mitt/src/index.ts | 123 ++ .../sources/path-to-regexp/LICENSE | 21 + .../sources/path-to-regexp/src/index.ts | 641 +++++++++ .../sources/server-sdk/LICENSE | 201 +++ .../sources/server-sdk/src/client/client.ts | 25 + .../sources/server-sdk/src/client/index.ts | 1 + .../client/internal/open-feature-client.ts | 436 ++++++ .../server-sdk/src/evaluation/evaluation.ts | 177 +++ .../server-sdk/src/evaluation/index.ts | 1 + .../sources/server-sdk/src/events/events.ts | 3 + .../sources/server-sdk/src/events/index.ts | 2 + .../events/internal/internal-event-emitter.ts | 9 + .../src/events/open-feature-event-emitter.ts | 21 + .../sources/server-sdk/src/hooks/hook.ts | 7 + .../sources/server-sdk/src/hooks/index.ts | 1 + .../sources/server-sdk/src/index.ts | 9 + .../sources/server-sdk/src/open-feature.ts | 277 ++++ .../in-memory-provider/flag-configuration.ts | 36 + .../in-memory-provider/in-memory-provider.ts | 139 ++ .../src/provider/in-memory-provider/index.ts | 1 + .../variant-not-found-error.ts | 15 + .../sources/server-sdk/src/provider/index.ts | 3 + .../server-sdk/src/provider/no-op-provider.ts | 38 + .../server-sdk/src/provider/provider.ts | 68 + .../sources/server-sdk/src/tracking/index.ts | 1 + .../server-sdk/src/tracking/tracking.ts | 12 + ...-storage-transaction-context-propagator.ts | 19 + .../src/transaction-context/index.ts | 3 + .../no-op-transaction-context-propagator.ts | 18 + .../transaction-context.ts | 63 + tests/harness/source-overlays.test.ts | 306 +++++ 138 files changed, 11271 insertions(+), 136 deletions(-) create mode 100644 packages/compiler/src/frontend/source-overlays.ts create mode 100644 tests/fixtures/source-overlays/project/emit.ts create mode 100644 tests/fixtures/source-overlays/project/flags.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json create mode 120000 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json create mode 120000 tests/fixtures/source-overlays/project/node_modules/@openfeature/core create mode 120000 tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk create mode 120000 tests/fixtures/source-overlays/project/node_modules/mitt create mode 120000 tests/fixtures/source-overlays/project/node_modules/path-to-regexp create mode 100644 tests/fixtures/source-overlays/project/package.json create mode 100644 tests/fixtures/source-overlays/project/require.cts create mode 100644 tests/fixtures/source-overlays/project/routes.ts create mode 100644 tests/fixtures/source-overlays/sources/core/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/core/src/client/client.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/client/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/eventing.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/events.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/filter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/open-feature.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/provider.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/tracking/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/type-guards.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/metadata.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/structure.ts create mode 100644 tests/fixtures/source-overlays/sources/mitt/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/mitt/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts create mode 100644 tests/harness/source-overlays.test.ts diff --git a/.gitignore b/.gitignore index e5b54a5fac..7d754d516f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules/ !tests/fixtures/npm/cases/*/node_modules/ !tests/library-mode/*/node_modules/ !tests/fixtures/provenance/node_modules/ +!tests/fixtures/source-overlays/project/node_modules/ +!tests/fixtures/source-overlays/project/node_modules/.pnpm/*/node_modules/ !tests/fixtures/fetch/node_modules/ !tests/fixtures/gateway-e2e/node_modules/ !tests/fixtures/node-types/node_modules/ @@ -32,6 +34,7 @@ output/ !tests/fixtures/npm/cases/workspace-copied/members/*/dist/ !tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/ !tests/fixtures/provenance/node_modules/*/dist/ +!tests/fixtures/source-overlays/project/node_modules/.pnpm/**/dist/ .scriptc/ *.log .DS_Store diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 2d12883fa6..0bc976aa5a 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -66,6 +66,7 @@ async function tryFastPath(): Promise { values["emit-ir"] || values.lib || values["provenance-sources"] || + process.argv.some((arg) => arg === "--sources" || arg.startsWith("--sources=")) || (values["external-types"] ?? []).length > 0 ) return null; @@ -191,6 +192,7 @@ async function coverageFastPath( values.optimization !== undefined || values["windows-subsystem"] !== undefined || values["provenance-sources"] || + process.argv.some((arg) => arg === "--sources" || arg.startsWith("--sources=")) || values["emit-ir"] || (values["npm-static"] ?? []).length > 0 || (values["external-types"] ?? []).length > 0 diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index d729de866d..1527f9a79a 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -13,10 +13,16 @@ import { buildEnvelope } from "../diagnostics/envelope.js"; import { renderWarnings } from "../diagnostics/render.js"; import { isNativeOptimization } from "../backend/optimization.js"; import { setProvenanceSources } from "../frontend/provenance-registry.js"; +import { + findSourceManifest, + installSourceOverlays, + loadSourceManifest, + sourceOverlayFallbacks, +} from "../frontend/source-overlays.js"; import type { CliHost, NativeCacheWarmProfile } from "./host.js"; import { resolveOutputOptions } from "./output-options.js"; import { selectOutputPaths } from "./paths.js"; -import { CLI_OPTIONS, USAGE } from "./usage.js"; +import { CLI_OPTIONS, normalizeCliArgs, USAGE } from "./usage.js"; /* The exit discipline: NEVER process.exit() after writing output. stdout/ * stderr to a PIPE are async streams — process.exit() drops whatever libuv @@ -46,7 +52,12 @@ function parseCli( typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }> > { try { - return parseArgs({ args, options: CLI_OPTIONS, allowPositionals: true, allowNegative: true }); + return parseArgs({ + args: normalizeCliArgs(args), + options: CLI_OPTIONS, + allowPositionals: true, + allowNegative: true, + }); } catch (err) { // parseArgs appends a paragraph about `--` and positionals to the // unknown-option message; the first sentence is the part that names @@ -57,6 +68,34 @@ function parseCli( } } +/** --sources: reads the manifest (the given path, or the nearest + * `.scriptc-pm/manifest.json` above `anchor`) and installs its overlays + * for the compile. Without the flag, any previous set is cleared. */ +function installSourcesFlag(value: string | undefined, anchor: string): void { + installSourceOverlays(null); + if (value === undefined) return; + const manifestPath = value !== "" ? resolve(value) : findSourceManifest(anchor); + if (manifestPath === null) { + fail( + `scriptc: --sources found no .scriptc-pm/manifest.json above ${dirname(anchor)} (run scriptc-pm sync, or pass --sources=)`, + ); + } + try { + installSourceOverlays(loadSourceManifest(manifestPath)); + } catch (e) { + fail(`scriptc: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** One stderr line per overlay instance the build fell back on. */ +function reportSourceFallbacks(): void { + for (const f of sourceOverlayFallbacks()) { + process.stderr.write( + `scriptc: sources: ${f.name}@${f.version} compiles from published JS (source overlay dropped: ${f.reason})\n`, + ); + } +} + async function main(args: string[], host: CliHost): Promise { const { values, positionals } = parseCli(args); const externalTypeArgs = values["external-types"] ?? []; @@ -72,6 +111,9 @@ async function main(args: string[], host: CliHost): Promise { } const [command, inputArg] = positionals; + if (values.sources !== undefined && values["provenance-sources"]) { + fail(`--sources and --provenance-sources are alternatives\n\n${USAGE}`); + } if (command === "cache") { if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`); @@ -88,6 +130,7 @@ async function main(args: string[], host: CliHost): Promise { values["detached-exit"] || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || + values.sources !== undefined || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || @@ -159,6 +202,7 @@ async function main(args: string[], host: CliHost): Promise { ); } const profilePath = resolve(profileArg); + installSourcesFlag(values.sources, profilePath); const libOutDir = values.out ? dirname(resolve(values.out)) : join(dirname(profilePath), ".scriptc"); @@ -169,6 +213,7 @@ async function main(args: string[], host: CliHost): Promise { emitIr: values["emit-ir"], sanitize: values.sanitize, }); + reportSourceFallbacks(); if (!result.ok) { const color = process.stderr.isTTY ?? false; process.stderr.write( @@ -322,6 +367,7 @@ async function main(args: string[], host: CliHost): Promise { // source "paths" at creation): attestations and source trees fetch (or // ride the content-addressed cache / the offline manifest), the registry // installs, and every fallback prints as a note — never a failure. + installSourcesFlag(values.sources, input); const provenance = values["provenance-sources"] ? await host.resolveProvenanceSources(input) : null; @@ -387,6 +433,7 @@ async function main(args: string[], host: CliHost): Promise { ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}), }); + if (command === "build") reportSourceFallbacks(); if (!result.ok) { if (printDiagnostics) { const envelope = buildEnvelope({ diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index a0388374b8..ccd7d78dcc 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -59,6 +59,13 @@ Options: direct import: own .d.ts, unminified JS, no build-transform markers). Packages that fail preflight are refused with a diagnostic. Eligible dependencies compile automatically. + --sources[=] + EXPERIMENTAL: compile augmented npm package instances + from the TypeScript source overlays a scriptc-pm + manifest describes (default: the nearest + .scriptc-pm/manifest.json above the entry). An overlay + that does not compile falls back to the package's + published JS --provenance-sources EXPERIMENTAL: compile npm dependencies from their provenance-attested SOURCE (fetched at the attested @@ -73,6 +80,22 @@ Options: -v, --version print the version `; +/** `--sources` takes an optional value (`--sources` or `--sources=`), + * which parseArgs cannot express: the bare flag becomes an empty value, + * meaning "find the manifest". A separate `--sources ` argument is + * not accepted, so a following positional is never swallowed. */ +export function normalizeCliArgs(args: readonly string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (arg === "--") { + out.push(...args.slice(out.length)); + break; + } + out.push(arg === "--sources" ? "--sources=" : arg); + } + return out; +} + export const CLI_OPTIONS = { out: { type: "string", short: "o" }, emit: { type: "string" }, @@ -89,6 +112,7 @@ export const CLI_OPTIONS = { ffi: { type: "string" }, "npm-static": { type: "string", multiple: true }, "provenance-sources": { type: "boolean", default: false }, + sources: { type: "string" }, "external-types": { type: "string", multiple: true }, lib: { type: "boolean", default: false }, profile: { type: "string" }, diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 9d0776d9a7..b593ac9b79 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -20,7 +20,7 @@ import { } from "../diagnostics/envelope.js"; import { renderDiagnostics } from "../diagnostics/render.js"; import type { LowerStats } from "../frontend/lowering/lowerer.js"; -import type { ProvenanceSources } from "../frontend/provenance-registry.js"; +import type { SourceOverlayReport } from "../frontend/source-overlays.js"; export interface CoverageInput { file: string; @@ -41,11 +41,12 @@ export interface CoverageInput { /** Each dependency's native compilation outcome, with the first refusal * reason for a refused package. */ npmStatic?: NpmStaticStatus[]; - /** --provenance-sources: the resolved source-mapped packages and the - * fallback notes — the report renders per-package attribution from - * statsByFile (statements inside each package's source dir). */ - provenance?: ProvenanceSources; - /** --provenance-sources: per-file statement attribution from the emit + /** --sources / --provenance-sources: the packages compiled from source + * overlays, the ones that fell back, and the producer's notes — the + * report renders per-package attribution from statsByFile (statements + * inside each package's overlay dir). */ + sourceOverlays?: SourceOverlayReport; + /** --sources / --provenance-sources: per-file statement attribution from the emit * pass (LowerResult.statsByFile). */ statsByFile?: Map; /** --provenance-sources: diagnostics of elided pure-annotated dead @@ -55,11 +56,15 @@ export interface CoverageInput { preflightFailed: boolean; } -/** One dependency's native compilation outcome. */ +/** One dependency's native compilation outcome. "source" compiles the + * package instance (`name@version`) from its --sources overlay; + * "source-fallback" dropped the overlay and the package compiles from its + * published JS (its own row says how that went). */ export interface NpmStaticStatus { package: string; - status: "static" | "refused"; - /** The first refusal reason for a refused package. */ + status: "static" | "refused" | "source" | "source-fallback"; + /** The first refusal reason for a refused package; the drop reason for a + * source fallback. */ detail?: string; } @@ -194,29 +199,40 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(` ${c(DIM, "npm package compilation:")}`); const widestP = Math.max(...npmStatic.map((s) => s.package.length)); for (const s of npmStatic) { + const detail = s.detail !== undefined ? ` ${c(DIM, `(${s.detail})`)}` : ""; const status = s.status === "static" ? c(GREEN, "static") - : c(RED, "refused") + (s.detail !== undefined ? ` ${c(DIM, `(${s.detail})`)}` : ""); + : s.status === "source" + ? c(GREEN, "static from source") + : s.status === "source-fallback" + ? c(YELLOW, "published JS") + detail + : c(RED, "refused") + detail; out.push(` ${s.package.padEnd(widestP)} ${status}`); } out.push(""); } - // --provenance-sources: per-package attribution — the answer to "did - // the DEPENDENCY's statements compile static?", computed by summing - // statsByFile entries under each package's source dir. Elided - // pure-annotated dead consts and fallback notes render here too, so - // the provenance story is complete in one place. - if (input.provenance !== undefined) { - const prov = input.provenance; - out.push( - ` ${c(BOLD, "provenance sources")} ${c(DIM, "(attested source compiled as program modules)")}`, - ); - for (const pkg of prov.packages) { + // --sources / --provenance-sources: per-package attribution — the + // answer to "did the DEPENDENCY's statements compile static?", computed + // by summing statsByFile entries under each overlay dir. Elided + // pure-annotated dead consts, fallbacks, and producer notes render here + // too, so the source story is complete in one place. + if (input.sourceOverlays !== undefined) { + const overlays = input.sourceOverlays; + out.push(` ${c(BOLD, "source overlays")} ${c(DIM, `(${overlays.origin})`)}`); + for (const pkg of overlays.packages) { + const origin = pkg.origin !== null ? ` ${c(DIM, pkg.origin)}` : ""; + out.push(` ${pkg.name}@${pkg.version}${origin}`); + if (pkg.fallback !== null) { + out.push( + ` ${c(YELLOW, "published JS")} ${c(DIM, `(source overlay dropped: ${pkg.fallback})`)}`, + ); + continue; + } let pTotal = 0; let pFailed = 0; - const dir = pkg.dir.endsWith("/") ? pkg.dir : `${pkg.dir}/`; + const dir = `${pkg.dir}/`; for (const [file, s] of input.statsByFile ?? new Map()) { if (!file.startsWith(dir)) continue; @@ -229,13 +245,6 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions const pOk = pTotal - pFailed; const pPct = pTotal === 0 ? 100 : Math.floor((pOk / pTotal) * 100); const pColor = pPct === 100 ? GREEN : pPct >= 75 ? YELLOW : RED; - const shortRepo = pkg.repo - .replace(/^git\+/, "") - .replace(/^https:\/\//, "") - .replace(/@refs\/.*$/, ""); - out.push( - ` ${pkg.name}@${pkg.version} ${c(DIM, `${shortRepo} @ ${pkg.commit.slice(0, 12)}`)}`, - ); out.push( ` ${pTotal} statements: ${pOk} static ${c(pColor, c(BOLD, `(${pPct}%)`))}, ${pFailed} fenced` + (elided > 0 @@ -243,7 +252,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions : ""), ); } - for (const note of prov.notes) out.push(` ${c(YELLOW, "note")} ${note}`); + for (const note of overlays.notes) out.push(` ${c(YELLOW, "note")} ${note}`); out.push(""); } diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index 841e2b57b1..8619c5d793 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -19,6 +19,7 @@ import { frontendInputsStillMatch, type FrontendInputSnapshot, } from "../frontend/input-tracker.js"; +import { sourceOverlayCacheIdentity } from "../frontend/source-overlays.js"; const SCHEMA = "scriptc.coverage-verdict.v2"; @@ -97,6 +98,10 @@ export function verdictKey( return null; } } + // --sources: the overlay set's identity joins the key; a set that cannot + // be cached (the network-fetching pilot) never replays. + const overlays = sourceOverlayCacheIdentity(); + if (overlays === null) return null; const npmStatic = opts.npmStatic === undefined ? "" @@ -120,13 +125,14 @@ export function verdictKey( opts.ffiProfilePath ?? "", ffi, external.join("\n"), + ...(overlays !== undefined ? [`sources:${overlays}`] : []), ].join("\0"), ); } function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null { if ( - coverage.provenance !== undefined || + coverage.sourceOverlays !== undefined || coverage.statsByFile !== undefined || coverage.provenanceElided !== undefined ) diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd0..02da6ba8fd 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -121,6 +121,9 @@ export interface EarlyExecutableCacheOptions { nodeVersion: string; implementation: string; implementationDependencies: CompilerImplementationDependency[]; + /** --sources: the overlay set's identity (manifest lock digest and + * bytes). Omitted without --sources, preserving existing keys. */ + sources?: string; } export type EarlyExecutableRouteOptions = Omit< @@ -232,6 +235,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { options.nodeVersion, options.implementation, ...ffiParts, + ...(options.sources !== undefined ? [`sources:${options.sources}`] : []), ]); } @@ -280,6 +284,7 @@ function routeKey(options: Omit 0 ? { npmStatic: fe.npmStatic } : {}), - // --provenance-sources: the per-package attribution inputs (the - // report aggregates statsByFile under each package's source dir). - ...(provenance !== null ? { provenance } : {}), + // --sources: the per-package attribution inputs (the report + // aggregates statsByFile under each overlay dir). + ...(sourceOverlays !== null ? { sourceOverlays } : {}), ...(lowered.statsByFile ? { statsByFile: lowered.statsByFile } : {}), ...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}), preflightFailed: false, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 367c147f85..bd381f434e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -78,7 +78,7 @@ import { stdlibGlobalNameOf, } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; -import { isProvenanceSourceFile } from "../provenance-registry.js"; +import { isSourceOverlayFile } from "../source-overlays.js"; import { ambientUndefVarRootOf, lowerImportEquals, @@ -603,7 +603,7 @@ function pureAnnotatedDeadConst(stmt: ts.Statement, sf: ts.SourceFile): boolean * shape. */ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { const sf = decl.getSourceFile(); - if (!isProvenanceSourceFile(sf.fileName)) return false; + if (!isSourceOverlayFile(sf.fileName)) return false; const stmt = decl.parent?.parent; if (!ts.isVariableStatement(stmt) || !pureAnnotatedDeadConst(stmt, sf)) return false; const diagsBefore = lowerer.diags.length; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d4cf827527..074a77a6c5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -189,7 +189,7 @@ import { UNDEFINED_T, VOID, } from "../../ir/ir.js"; -import { provenanceActive } from "../provenance-registry.js"; +import { sourceOverlaysActive } from "../source-overlays.js"; import { ambientDtsPath, canonicalBuiltinModule, @@ -2714,7 +2714,7 @@ export class Lowerer { * active and this is the emit/discovery lane — mirror the CALLER's * suppressStats guard, this method only gates remainder). */ bumpFileStat(file: string, kind: "total" | "failed"): void { - if (this.remainder || !provenanceActive()) return; + if (this.remainder || !sourceOverlaysActive()) return; let s = this.statsByFile.get(file); if (!s) this.statsByFile.set(file, (s = { total: 0, failed: 0 })); if (kind === "total") s.total += 1; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 2ec9553f67..f5aa7f9c05 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -19,8 +19,28 @@ import { npmStaticOffenders, npmStaticPackageOfPath, } from "./npm-static.js"; -import { clearResolveCaches, resolveBareModule, resolveWorkspaceSourceModule } from "./resolve.js"; -import { provenanceEntryFor } from "./provenance-registry.js"; +import { + clearResolveCaches, + resolveBareModule, + resolveSourceOverlayModule, + resolveWorkspaceSourceModule, +} from "./resolve.js"; +import { + activeSourceOverlays, + beginSourceOverlayCompile, + dropSourceOverlay, + isSourceOverlayFile, + restoreSourceOverlayDrops, + snapshotSourceOverlayDrops, + sourceOverlayDropReason, + sourceOverlayOfFile, + sourceOverlayReport, + sourceOverlaySet, + sourceOverlaysActive, + takeSourceOverlayProblems, + type SourceOverlayReport, +} from "./source-overlays.js"; +import { npmStaticRefusalDiag } from "../diagnostics/diagnostic.js"; import { isJsSourceFileName } from "./tsc-codes.js"; import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js"; import { lowerToIr, type LowerOptions, type LowerResult } from "./lowering/lowerer.js"; @@ -72,6 +92,9 @@ export interface Frontend { npmStatic: NpmStaticStatus[]; /** Each judged npm package's first import site, used to anchor refusals. */ npmImportSites: ReadonlyMap; + /** --sources: the overlay instances the final program compiles from + * source, and every instance that fell back to published JS. */ + sourceOverlays: () => SourceOverlayReport | null; /** Releases the frontend's resources (the spawned tsgo server). Call * exactly once, after the last lower(). */ dispose: () => void; @@ -122,9 +145,9 @@ function detectAutoPackages( // experimental-import.ts), typed by their declarations. if (spec === CONTRACT_TYPES_MODULE || spec === THREADS_MODULE || spec === EXPERIMENTAL_MODULE) continue; - // Attested entries already resolve to program sources, so the - // published package's declaration eligibility does not apply. - if (provenanceEntryFor(spec) !== null) continue; + // Augmented instances resolve to program sources, so the published + // package's declaration eligibility does not apply. + if (resolveSourceOverlayModule(sf.fileName, spec) !== null) continue; if (resolveWorkspaceSourceModule(sf.fileName, spec) !== null) continue; if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; // Bare builtin names ("fs", "path") are the builtin machinery's @@ -198,6 +221,209 @@ function packagesNamedByDiag(message: string, optedIn: ReadonlySet): Set return hits; } +/** Where a diagnostic sits, relative to its overlay ("src/x.ts:12"). */ +function overlaySite( + file: string, + overlayDir: string, + text: string | undefined, + start: number, +): string { + const rel = file.slice(overlayDir.length + 1); + if (text === undefined) return rel; + let line = 1; + for (let i = 0; i < start && i < text.length; i++) if (text.charCodeAt(i) === 10) line++; + return `${rel}:${line}`; +} + +/** --sources: the active overlay instances a load implicates, each with the + * first reason. An instance falls back to published JS when its source + * fails preflight (any diagnostic anchored in an overlay file — module-form + * refusals and type errors alike), when a program type error names its + * specifier or files, when a top-level require() edge targets it (the + * CommonJS shape of a published package is not synthesized for source), + * or when the resolver noted a checker disagreement. */ +function sourceOverlayCulprits( + load: LoadResult, + preflight: readonly ScrDiagnostic[], +): Map { + const culprits = takeSourceOverlayProblems(); + if (!sourceOverlaysActive()) return culprits; + const add = (key: string, reason: string): void => { + if (!culprits.has(key)) culprits.set(key, reason); + }; + const texts = new Map(); + for (const sf of [load.entry, ...load.moduleOrder]) texts.set(sf.fileName, sf.text); + const active = activeSourceOverlays(); + for (const d of preflight) { + const owner = sourceOverlayOfFile(d.loc.file); + if (owner !== null && isSourceOverlayFile(d.loc.file)) { + const site = overlaySite(d.loc.file, owner.overlayDir, texts.get(d.loc.file), d.loc.start); + add(owner.key, `${d.code} at ${site}: ${d.message}`); + continue; + } + if (d.code !== "SC0001") continue; + for (const instance of active) { + const named = + d.message.includes(`'"${instance.name}"'`) || + d.message.includes(`'"${instance.name}/`) || + d.message.includes(`${instance.overlayDir}/`); + if (named) + add(instance.key, `the program does not typecheck against its source: ${d.message}`); + } + } + for (const sf of [load.entry, ...load.moduleOrder]) { + for (const stmt of sf.statements) { + for (const req of requiresOf(stmt)) { + const target = resolveSourceOverlayModule(sf.fileName, req.spec, "require"); + const owner = target === null ? null : sourceOverlayOfFile(target); + if (owner === null) continue; + add( + owner.key, + `require() of '${req.spec}' from ${sf.fileName} (CommonJS interop for source overlays is not implemented)`, + ); + } + } + } + for (const [key, reason] of takeSourceOverlayProblems()) add(key, reason); + return culprits; +} + +function dropSourceOverlays(culprits: ReadonlyMap): void { + for (const [key, reason] of culprits) dropSourceOverlay(key, reason); +} + +/** The first diagnostic outside every overlay, as a one-line reason. */ +function surfaceReason(verb: string, diagnostics: readonly ScrDiagnostic[]): string { + const d = diagnostics.find((x) => sourceOverlayOfFile(x.loc.file) === null) ?? diagnostics[0]!; + return `the program does not ${verb} against its source surface (${d.code} in ${d.loc.file}: ${d.message.split("\n")[0]})`; +} + +/** The keys of the overlays a frontend compiles from source. */ +function frontendOverlayKeys(fe: Frontend): string[] { + return (fe.sourceOverlays()?.packages ?? []).filter((p) => p.fallback === null).map((p) => p.key); +} + +/** The keys of active instances with files in the program. */ +function usedSourceOverlays(load: LoadResult): Set { + const used = new Set(); + if (!sourceOverlaysActive()) return used; + for (const sf of load.program.getImplementationSourceFiles()) { + const owner = sourceOverlayOfFile(sf.fileName); + if (owner !== null && isSourceOverlayFile(sf.fileName)) used.add(owner.key); + } + return used; +} + +/** True when a reloaded frontend can be lowered at all: a clean preflight + * and no refused package (executable and library builds stop on both). */ +function frontendLowerable(fe: Frontend, coverage: boolean): boolean { + return ( + fe.preflight.length === 0 && + (coverage || !fe.npmStatic.some((entry) => entry.status === "refused")) + ); +} + +/** --sources: lowering can still refuse a statement after preflight + * passed. A refusal inside an overlay drops that instance; a failure + * outside every overlay probes the published surface with every used + * overlay dropped (the program may only fail against a declared source + * type the published JS's inferred surface avoids) and keeps the probe + * only when it lowers cleanly. Each fallback reloads the frontend, so the + * dropped packages resolve to their published JS. The arrays and maps + * callers read are refreshed in place, so copies of the wrapper observe + * the reload too. */ +function withSourceOverlayLoweringFallback( + first: Frontend, + reload: () => Frontend, + entryPath: string, +): Frontend { + let current = first; + const adopt = (next: Frontend): void => { + current.dispose(); + current = next; + fe.preflight.splice(0, fe.preflight.length, ...next.preflight); + fe.npmStatic.splice(0, fe.npmStatic.length, ...next.npmStatic); + const sites = fe.npmImportSites as Map; + sites.clear(); + for (const [k, v] of next.npmImportSites) sites.set(k, v); + }; + /** The failure a reload that cannot be lowered reports, shaped like the + * checks executable and library builds run before lowering. */ + const unlowerable = (stats: LowerResult["stats"], coverage: boolean): LowerResult => ({ + module: null, + diagnostics: [ + ...current.preflight, + ...(coverage ? [] : current.npmStatic) + .filter((entry) => entry.status === "refused") + .map((entry) => + npmStaticRefusalDiag( + entry.package, + entry.detail ?? "its static compilation was refused", + current.npmImportSites.get(entry.package) ?? { file: entryPath, start: 0, end: 0 }, + ), + ), + ], + runtimeFences: [], + stats, + }); + const fe: Frontend = { + preflight: [...first.preflight], + npmStatic: [...first.npmStatic], + npmImportSites: new Map(first.npmImportSites), + entryText: () => current.entryText(), + entryExports: () => current.entryExports(), + entryContract: () => current.entryContract(), + sourceTexts: () => current.sourceTexts(), + sourceOverlays: () => current.sourceOverlays(), + dispose: () => current.dispose(), + lower: (opts) => { + const coverage = opts.coverage === true; + for (;;) { + const result = current.lower(opts); + const culprits = new Map(); + let texts: Map | null = null; + for (const d of result.diagnostics) { + const owner = sourceOverlayOfFile(d.loc.file); + if (owner === null || !isSourceOverlayFile(d.loc.file) || culprits.has(owner.key)) + continue; + texts ??= current.sourceTexts(); + const site = overlaySite( + d.loc.file, + owner.overlayDir, + texts.get(d.loc.file), + d.loc.start, + ); + culprits.set(owner.key, `${d.code} at ${site}: ${d.message}`); + } + if (culprits.size > 0) { + dropSourceOverlays(culprits); + adopt(reload()); + if (!frontendLowerable(current, coverage)) return unlowerable(result.stats, coverage); + continue; + } + // Coverage keeps its blockers rather than probing. + const used = frontendOverlayKeys(current); + if (coverage || result.diagnostics.length === 0 || used.length === 0) return result; + const snapshot = snapshotSourceOverlayDrops(); + const reason = surfaceReason("compile", result.diagnostics); + for (const key of used) dropSourceOverlay(key, reason); + const probe = reload(); + if (frontendLowerable(probe, false)) { + const lowered = probe.lower(opts); + if (lowered.diagnostics.length === 0) { + adopt(probe); + return lowered; + } + } + probe.dispose(); + restoreSourceOverlayDrops(snapshot); + return result; + } + }, + }; + return fe; +} + export function runFrontend( entryPath: string, loadProgram: ProgramLoader, @@ -227,9 +453,15 @@ export function runFrontend( // selections bypass eligibility heuristics, but never compilation checks. const explicit: readonly string[] = npmStatic !== undefined && typeof npmStatic !== "string" ? npmStatic : []; - return loadFrontend(entryPath, trackedLoader, "lib", externalTypes, [ - ...new Set([...libraryNpmStatic, ...explicit]), - ]); + const load = (): Frontend => + loadFrontend(entryPath, trackedLoader, "lib", externalTypes, [ + ...new Set([...libraryNpmStatic, ...explicit]), + ]); + beginSourceOverlayCompile(); + const first = load(); + return sourceOverlaysActive() + ? withSourceOverlayLoweringFallback(first, load, entryPath) + : first; } catch (error) { for (const load of active) { // Preserve the original failure and attempt every remaining cleanup. @@ -266,24 +498,34 @@ function loadFrontend( if (npmStatic === "auto" || npmStatic === "lib") { const scout = loadProgram(entryPath, { externalTypes }); let retained = false; + let overlayCulprits = new Map(); try { const scoutPreflight = checkPreflight(scout); - requested = - npmStatic === "lib" - ? [...libraryNpmStatic, ...detectAutoPackages(scout, statuses, "lib", judged, npmSites)] - : detectAutoPackages(scout, statuses); - // With no package to opt in, the scout already IS the final frontend: - // same roots, resolution posture, preflight, and module order. Retain it - // instead of spawning a second tsgo server and checking the whole graph - // again — the common library-mode path has no bare npm imports. - if (requested.length === 0) { - reusableScout = scout; - reusablePreflight = scoutPreflight; - retained = true; + // --sources: overlays that already fail here fall back before any + // package selection depends on them. + overlayCulprits = sourceOverlayCulprits(scout, scoutPreflight); + if (overlayCulprits.size === 0) { + requested = + npmStatic === "lib" + ? [...libraryNpmStatic, ...detectAutoPackages(scout, statuses, "lib", judged, npmSites)] + : detectAutoPackages(scout, statuses); + // With no package to opt in, the scout already IS the final frontend: + // same roots, resolution posture, preflight, and module order. Retain it + // instead of spawning a second tsgo server and checking the whole graph + // again — the common library-mode path has no bare npm imports. + if (requested.length === 0) { + reusableScout = scout; + reusablePreflight = scoutPreflight; + retained = true; + } } } finally { if (!retained) scout.dispose(); } + if (overlayCulprits.size > 0) { + dropSourceOverlays(overlayCulprits); + return loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + } } else if (npmStatic !== undefined) { requested = [...new Set(npmStatic)]; } @@ -434,6 +676,49 @@ function loadFrontend( } } + // --sources: npm packages joining the program can expose new overlay + // failures (a published CommonJS dependency's require() of an augmented + // package, a preflight error only the full graph reaches). Drop those + // instances and restart the whole selection without them. + const lateCulprits = sourceOverlayCulprits(load, preflight); + if (lateCulprits.size > 0) { + dropSourceOverlays(lateCulprits); + load.dispose(); + return loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + } + const overlaysUsed = usedSourceOverlays(load); + // The preflight last resort: errors no overlay is blamed for may still + // come from typing the program against the overlays' source surface. + // Probe with every used overlay dropped and keep the probe only when it + // preflights cleanly. + if (overlaysUsed.size > 0 && preflight.length > 0) { + const snapshot = snapshotSourceOverlayDrops(); + const reason = surfaceReason("typecheck", preflight); + for (const key of overlaysUsed) dropSourceOverlay(key, reason); + const probe = loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + if ( + probe.preflight.length === 0 && + !probe.npmStatic.some((entry) => entry.status === "refused") + ) { + load.dispose(); + return probe; + } + probe.dispose(); + restoreSourceOverlayDrops(snapshot); + } + for (const instance of sourceOverlaySet()?.instances ?? []) { + const dropped = sourceOverlayDropReason(instance.key); + if (dropped !== null) { + statuses.push({ + package: `${instance.name}@${instance.version}`, + status: "source-fallback", + detail: `source overlay: ${dropped}`, + }); + } else if (overlaysUsed.has(instance.key)) { + statuses.push({ package: `${instance.name}@${instance.version}`, status: "source" }); + } + } + const finalLoad = load; return { preflight, @@ -482,6 +767,7 @@ function loadFrontend( }), npmStatic: statuses, npmImportSites: npmSites, + sourceOverlays: () => sourceOverlayReport(overlaysUsed), dispose: finalLoad.dispose, }; } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 3a4ec16479..991ce23ee7 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -59,6 +59,8 @@ import { projectDtsRuntimeSibling, resolveBareModule, resolveProjectModule, + resolveSourceOverlayModule, + sourceOverlayPaths, resolveTypeDirective, setProjectPathMappings, setProjectRealm, @@ -87,7 +89,6 @@ import type { NpmStaticDeclarationProperties, NpmStaticOverloadSignature, } from "./npm-static-declaration-syntax.js"; -import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js"; import { ambientDtsPath, @@ -693,13 +694,15 @@ function loadProgram7( // so tsgo's OWN resolution of the bare specifiers lands on the same // source files the preflight resolver answers — the checker types the // driver against the package's real TypeScript, not its shipped .d.ts. - const provenance = provenancePaths(); - if (provenance !== null || externalTypes.size > 0) { + // --sources: the active overlays' exported specifiers map to their + // source files (resolveSourceOverlayModule answers the same files). + const overlays = sourceOverlayPaths(); + if (overlays !== null || externalTypes.size > 0) { const paths: Record = { ...(configuredPaths as Record | undefined), - // Keep an index-signature source for native lowering when provenance is absent. + // Keep an index-signature source for native lowering when no overlay is active. // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(provenance ?? {}), + ...(overlays ?? {}), }; for (const [specifier, declarationPath] of externalTypes) { paths[specifier] = [declarationPath]; @@ -3251,10 +3254,10 @@ function resolveNpmImport7( if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { return null; } - // --provenance-sources: a registered specifier is NOT an npm import — - // its attested source compiles as program modules (resolveProjectModule - - if (provenanceEntryFor(specifier) !== null) return null; + // --sources: an import landing in an augmented instance is NOT an npm + // import — its source compiles as program modules (resolveProjectModule + // answers it). + if (resolveSourceOverlayModule(fromFileName, specifier, resolutionKind) !== null) return null; const resolved = resolveBareModule(fromFileName, specifier, undefined, resolutionKind); if (!resolved) return null; if (!isNodeModulesPath(resolved.typesFile)) { diff --git a/packages/compiler/src/frontend/provenance-core.ts b/packages/compiler/src/frontend/provenance-core.ts index 646aa2d53b..208a040213 100644 --- a/packages/compiler/src/frontend/provenance-core.ts +++ b/packages/compiler/src/frontend/provenance-core.ts @@ -424,6 +424,7 @@ export async function resolveProvenanceSourcesWithParser( repo, commit, dir, + installedDir: installed.dir, entries, }; if (pkg.sourceVersion !== undefined) { diff --git a/packages/compiler/src/frontend/provenance-registry.ts b/packages/compiler/src/frontend/provenance-registry.ts index 745bb04475..17d5203d5e 100644 --- a/packages/compiler/src/frontend/provenance-registry.ts +++ b/packages/compiler/src/frontend/provenance-registry.ts @@ -1,3 +1,7 @@ +import { realpathSync } from "node:fs"; +import { relative } from "node:path"; +import { installSourceOverlays, type SourceOverlayInstance } from "./source-overlays.js"; + export interface ProvenancePackageSource { /** Package name ("cookie"). */ name: string; @@ -15,6 +19,8 @@ export interface ProvenancePackageSource { /** Absolute path of the package's directory inside the cached source * tree (== the tree root except in monorepos). */ dir: string; + /** The installed (published) package directory the sources replace. */ + installedDir: string; /** specifier → absolute mapped source file ("cookie" → …/src/index.ts). */ entries: Record; } @@ -26,64 +32,56 @@ export interface ProvenanceSources { notes: string[]; } -interface RegistryState { - bySpecifier: Map; - packageDirs: string[]; - sources: ProvenanceSources; -} - -let state: RegistryState | null = null; - -/** Installs the resolved provenance sources for the current compile. - * Passing null (or an empty set) clears the registry. */ +/** Installs the pilot's resolved provenance sources for the current + * compile as a source-overlay set (source-overlays.ts) — the pilot is one + * more overlay producer, keyed like a manifest by each package's installed + * directory. Passing null clears the overlay registry. The set never joins + * a build cache: the pilot resolves sources from the network. */ export function setProvenanceSources(sources: ProvenanceSources | null): void { - if (sources === null || sources.packages.length === 0) { - state = sources === null ? null : { bySpecifier: new Map(), packageDirs: [], sources }; + if (sources === null) { + installSourceOverlays(null); return; } - const bySpecifier = new Map(); - const packageDirs: string[] = []; + const instances: SourceOverlayInstance[] = []; + const notes = [...sources.notes]; for (const pkg of sources.packages) { - packageDirs.push(pkg.dir.endsWith("/") ? pkg.dir : `${pkg.dir}/`); + const key = realDirOrNull(pkg.installedDir); + const overlayDir = realDirOrNull(pkg.dir); + if (key === null || overlayDir === null) { + notes.push(`${pkg.name}: installed or source directory missing; published package used`); + continue; + } + const exports: Record = {}; + const nameLen = pkg.name.split("/").length; for (const [spec, file] of Object.entries(pkg.entries)) { - bySpecifier.set(spec, file); + const parts = spec.split("/"); + const subpath = parts.length === nameLen ? "." : `./${parts.slice(nameLen).join("/")}`; + exports[subpath] = `./${relative(overlayDir, realpathSync(file)).split("\\").join("/")}`; } + instances.push({ + key, + name: pkg.name, + version: pkg.version, + overlayDir, + exports, + origin: `${pkg.repo + .replace(/^git\+/, "") + .replace(/^https:\/\//, "") + .replace(/@refs\/.*$/, "")} @ ${pkg.commit.slice(0, 12)}`, + }); } - state = { bySpecifier, packageDirs, sources }; -} - -/** The active sources (for reports), or null when the flag is off. */ -export function provenanceSources(): ProvenanceSources | null { - return state?.sources ?? null; + installSourceOverlays({ + origin: "--provenance-sources", + cacheIdentity: null, + instances, + notes, + }); } -/** The mapped source entry for a bare specifier, or null. */ -export function provenanceEntryFor(specifier: string): string | null { - return state?.bySpecifier.get(specifier) ?? null; -} - -/** True when any provenance package is registered. */ -export function provenanceActive(): boolean { - return state !== null && state.bySpecifier.size > 0; -} - -/** True when `fileName` lives inside a registered package's source tree — - * the gate for the third-party-source policies (the pure-annotated - * dead-const elision, the per-file statement attribution). */ -export function isProvenanceSourceFile(fileName: string): boolean { - if (state === null) return false; - for (const dir of state.packageDirs) { - if (fileName.startsWith(dir)) return true; +function realDirOrNull(path: string): string | null { + try { + return realpathSync(path).split("\\").join("/").replace(/\/+$/, ""); + } catch { + return null; } - return false; -} - -/** tsconfig "paths" for the registered entries — loadProgram feeds these - * to tsgo so the checker resolves registered bare specifiers to the same - * source files the preflight resolver answers. */ -export function provenancePaths(): Record | null { - if (state === null || state.bySpecifier.size === 0) return null; - const paths: Record = {}; - for (const [spec, file] of state.bySpecifier) paths[spec] = [file]; - return paths; } diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index db440c1996..b4f56ebb63 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -21,7 +21,12 @@ import { npmStaticPackageOfPath, npmStaticTransformPkgJson, } from "./npm-static.js"; -import { provenanceEntryFor } from "./provenance-registry.js"; +import { + activeSourceOverlayAt, + activeSourceOverlays, + noteSourceOverlayProblem, + sourceOverlayNameActive, +} from "./source-overlays.js"; import { trackedAccessibleEntries, trackedDirectoryExists, @@ -599,13 +604,6 @@ export function nearestInvalidPackageJsonPath(fromFile: string): string | null { * Relative specifiers, real node_modules packages, and builtins are other * resolvers' business; callers try those first. */ export function resolveProjectImport(fromFile: string, specifier: string): string | null { - // --provenance-sources (flag-gated; the registry is empty otherwise): a - // registered bare specifier answers its attested SOURCE entry — the one - // chokepoint that makes preflight's user-module edges, the module - // order, and the CJS link check all agree the package compiles as - - const provenance = provenanceEntryFor(specifier); - if (provenance !== null) return normalizeResolvedPath(provenance); const viaPaths = resolveViaProjectPaths(specifier); if (viaPaths !== null) return normalizeResolvedPath(viaPaths); const pkgDir = nearestPkgDir(dirname(resolve(fromFile))); @@ -653,6 +651,11 @@ export function resolveProjectModule(fromFile: string, specifier: string): strin } if (specifier.startsWith("node:")) return null; return ( + // --sources: an import landing in an augmented instance answers the + // instance's SOURCE entry — the one chokepoint that makes preflight's + // user-module edges, the module order, and the CJS link check agree + // the package compiles as program modules. + resolveSourceOverlayModule(fromFile, specifier) ?? resolveProjectImport(fromFile, specifier) ?? resolveWorkspaceSourceModule(fromFile, specifier)?.typesFile ?? null @@ -958,6 +961,105 @@ export function resolveBareModule( return npmStatic ? passOnce("js") : (passOnce("types") ?? passOnce("js")); } +/** The conditions an overlay's exports resolve with. Overlays point every + * condition at source, so "types" is accepted alongside Node's runtime + * conditions for the edge kind. */ +const OVERLAY_IMPORT_CONDITIONS = new Set(["types", "import", "node", "default"]); +const OVERLAY_REQUIRE_CONDITIONS = new Set(["types", "require", "node", "default"]); + +/** --sources: resolves a bare specifier to an augmented package + * instance's SOURCE file, or null. Node's node_modules walk from the + * importing file finds the package directory; when its realpath is an + * active instance, the subpath resolves against the instance's exports + * inside its overlay directory. Problems that would make the checker and + * this resolver disagree (a missing export, a second installed copy the + * checker's global "paths" would also redirect) are noted for the + * frontend, which drops the instance and reloads. */ +export function resolveSourceOverlayModule( + fromFile: string, + specifier: string, + resolutionKind: "import" | "require" = "import", +): string | null { + if ( + isRelativeSpecifier(specifier) || + isAbsolute(specifier) || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + const pkgName = packageNameOfSpecifier(specifier); + if (!sourceOverlayNameActive(pkgName)) return null; + const rest = specifier.slice(pkgName.length).replace(/^\//, ""); + const subpath = rest === "" ? "." : `./${rest}`; + for (let dir = dirname(resolve(fromFile)); ;) { + const candidate = join(dir, "node_modules", pkgName); + if (isDirectory(candidate)) { + const real = realpathOr(candidate); + const instance = activeSourceOverlayAt(real); + if (instance === null) { + for (const other of activeSourceOverlays()) { + if (other.name !== pkgName) continue; + noteSourceOverlayProblem( + other.key, + `ambiguous-name: another installed copy of ${pkgName} (${real}) is imported from ${fromFile}`, + ); + } + return null; + } + const target = resolveExports( + instance.exports, + subpath, + resolutionKind === "require" ? OVERLAY_REQUIRE_CONDITIONS : OVERLAY_IMPORT_CONDITIONS, + ); + if (target === null) { + noteSourceOverlayProblem(instance.key, `'${specifier}' is not exported by the overlay`); + return null; + } + const file = join(instance.overlayDir, target); + if (!isFile(file)) { + noteSourceOverlayProblem( + instance.key, + `'${specifier}' maps to ${target}, which does not exist in the overlay`, + ); + return null; + } + return normalizeResolvedPath(file); + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** tsconfig "paths" for the active overlays: each exported subpath of an + * instance maps its bare specifier to the instance's source file, so the + * checker resolves the same files resolveSourceOverlayModule answers. + * Global by name, which is why names with several augmented instances + * start dropped (source-overlays.ts). */ +export function sourceOverlayPaths(): Record | null { + const active = activeSourceOverlays(); + if (active.length === 0) return null; + const paths: Record = {}; + for (const instance of active) { + const ex = instance.exports; + const keys = + ex !== null && + typeof ex === "object" && + !Array.isArray(ex) && + Object.keys(ex).every((k) => k.startsWith(".")) + ? Object.keys(ex) + : ["."]; + for (const key of keys) { + const target = resolveExports(ex, key, OVERLAY_IMPORT_CONDITIONS); + if (target === null) continue; + const specifier = key === "." ? instance.name : `${instance.name}/${key.slice(2)}`; + paths[specifier] = [normalizeResolvedPath(join(instance.overlayDir, target))]; + } + } + return paths; +} + export function resolveWorkspaceSourceModule( fromFile: string, specifier: string, diff --git a/packages/compiler/src/frontend/source-overlays.ts b/packages/compiler/src/frontend/source-overlays.ts new file mode 100644 index 0000000000..f40c2971c1 --- /dev/null +++ b/packages/compiler/src/frontend/source-overlays.ts @@ -0,0 +1,380 @@ +/* Source overlays: TypeScript source trees that replace installed npm + * packages' published JavaScript (`scriptc build --sources`). + * + * A package manager (scriptc-pm) materializes each augmented package + * instance's upstream source into an overlay directory and describes the + * set in `.scriptc-pm/manifest.json`. The compiler never fetches or + * verifies anything: it reads the manifest, keys every instance by the + * REALPATH of its installed directory (so two installed versions of one + * name are distinct instances), and the resolver redirects bare imports + * that land in an instance's installed directory to the instance's + * `exports` inside its overlay directory (resolve.ts, + * resolveSourceOverlayModule). Overlay files live outside node_modules, so + * they compile as program modules. + * + * Every instance can fall back: the frontend drops an instance's redirect + * (pipeline.ts) when its source fails preflight or lowering, when a + * require() edge targets it, or when the checker and the resolver could + * disagree about it. The package then compiles from its published JS + * exactly as it would without --sources. Drops reset per compile. */ + +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +/** One augmented package instance, as the compiler uses it. */ +export interface SourceOverlayInstance { + /** realpath(installedDir): the instance identity. */ + key: string; + name: string; + version: string; + /** realpath(overlayDir), without a trailing slash. */ + overlayDir: string; + /** package.json-shaped "exports" resolved against overlayDir. */ + exports: unknown; + /** Human-readable source origin for reports ("github.com/o/r @ abc123"), + * or null when the producer did not say. */ + origin: string | null; +} + +/** A complete overlay set, ready to install for a compile. */ +export interface SourceOverlaySet { + /** Where the set came from (a manifest path, or "--provenance-sources"). */ + origin: string; + /** Build-cache identity for the set, or null when the set cannot be + * cached (the network-fetching --provenance-sources pilot). */ + cacheIdentity: string | null; + instances: SourceOverlayInstance[]; + /** Producer notes (manifest fallbacks, skipped instances). */ + notes: string[]; +} + +/** The per-package lines of the coverage report's source section. */ +export interface SourceOverlayReport { + origin: string; + packages: { + /** The instance key (realpath of its installed directory). */ + key: string; + name: string; + version: string; + dir: string; + origin: string | null; + /** Null when the overlay compiled; the reason otherwise. */ + fallback: string | null; + }[]; + notes: string[]; +} + +export const SOURCE_MANIFEST_RELATIVE_PATH = ".scriptc-pm/manifest.json"; + +interface OverlayState { + set: SourceOverlaySet; + byKey: Map; + /** Install-time drops (ambiguous names) — the starting point of every + * compile's drop set. */ + initialDrops: Map; + drops: Map; + /** Problems the resolver noticed while answering (missing export + * targets, checker disagreement), drained by the frontend loop. */ + problems: Map; +} + +let state: OverlayState | null = null; + +/** Installs `set` for the following compiles; null clears it. Names with + * more than one augmented instance cannot be redirected for the checker + * until it resolves per instance (tsgo receives one global `paths` entry + * per name), so those instances start dropped. */ +export function installSourceOverlays(set: SourceOverlaySet | null): void { + if (set === null) { + state = null; + return; + } + const byKey = new Map(); + const byName = new Map(); + for (const instance of set.instances) { + byKey.set(instance.key, instance); + const list = byName.get(instance.name) ?? []; + list.push(instance); + byName.set(instance.name, list); + } + const initialDrops = new Map(); + for (const [name, list] of byName) { + if (list.length < 2) continue; + for (const instance of list) { + initialDrops.set( + instance.key, + `ambiguous-name: ${list.length} augmented instances of ${name} (only single-instance names are redirected)`, + ); + } + } + state = { set, byKey, initialDrops, drops: new Map(initialDrops), problems: new Map() }; +} + +/** Restores the install-time drop set; called once per compile, before + * the first program load. */ +export function beginSourceOverlayCompile(): void { + if (state === null) return; + state.drops = new Map(state.initialDrops); + state.problems.clear(); +} + +export function sourceOverlaySet(): SourceOverlaySet | null { + return state?.set ?? null; +} + +/** True when at least one instance is currently redirected. */ +export function sourceOverlaysActive(): boolean { + return state !== null && state.drops.size < state.byKey.size; +} + +/** The redirected (not dropped) instances. */ +export function activeSourceOverlays(): SourceOverlayInstance[] { + if (state === null) return []; + const s = state; + return s.set.instances.filter((instance) => !s.drops.has(instance.key)); +} + +/** True when some active instance is named `name` — the cheap pre-check + * before the resolver walks node_modules. */ +export function sourceOverlayNameActive(name: string): boolean { + if (state === null) return false; + for (const instance of state.set.instances) { + if (instance.name === name && !state.drops.has(instance.key)) return true; + } + return false; +} + +/** The active instance whose installed directory realpaths to `realDir`. */ +export function activeSourceOverlayAt(realDir: string): SourceOverlayInstance | null { + const instance = state?.byKey.get(realDir); + return instance !== undefined && !state!.drops.has(instance.key) ? instance : null; +} + +/** The instance (active or dropped) whose overlay directory contains + * `file`, or null. */ +export function sourceOverlayOfFile(file: string): SourceOverlayInstance | null { + if (state === null) return null; + const norm = file.split("\\").join("/"); + for (const instance of state.set.instances) { + const dir = instance.overlayDir; + if (norm.startsWith(dir) && norm[dir.length] === "/") return instance; + } + return null; +} + +/** True when `file` belongs to an active overlay — the gate for the + * third-party-source lowering policies (the @__PURE__ dead-const elision, + * per-file statement attribution). */ +export function isSourceOverlayFile(file: string): boolean { + const instance = sourceOverlayOfFile(file); + return instance !== null && !state!.drops.has(instance.key); +} + +export function dropSourceOverlay(key: string, reason: string): void { + if (state === null || !state.byKey.has(key) || state.drops.has(key)) return; + state.drops.set(key, reason); +} + +/** The current drop set, for a probe that may need to undo its drops. */ +export function snapshotSourceOverlayDrops(): Map { + return new Map(state?.drops ?? []); +} + +export function restoreSourceOverlayDrops(snapshot: ReadonlyMap): void { + if (state !== null) state.drops = new Map(snapshot); +} + +export function noteSourceOverlayProblem(key: string, reason: string): void { + if (state === null || state.drops.has(key) || state.problems.has(key)) return; + state.problems.set(key, reason); +} + +/** Drains the problems the resolver noted since the last call. */ +export function takeSourceOverlayProblems(): Map { + if (state === null) return new Map(); + const out = new Map(state.problems); + state.problems.clear(); + return out; +} + +export function sourceOverlayDropReason(key: string): string | null { + return state?.drops.get(key) ?? null; +} + +/** The instances the last compile dropped, with their reasons. */ +export function sourceOverlayFallbacks(): { name: string; version: string; reason: string }[] { + if (state === null) return []; + const out: { name: string; version: string; reason: string }[] = []; + for (const instance of state.set.instances) { + const reason = state.drops.get(instance.key); + if (reason !== undefined) out.push({ name: instance.name, version: instance.version, reason }); + } + return out; +} + +/** The build-cache identity: undefined when no set is installed, null when + * the installed set cannot be cached. */ +export function sourceOverlayCacheIdentity(): string | null | undefined { + if (state === null) return undefined; + return state.set.cacheIdentity; +} + +/** Report rows: every instance the program reached (`used` holds the keys + * of active instances with files in the final program) plus every dropped + * instance, in manifest order. */ +export function sourceOverlayReport(used: ReadonlySet): SourceOverlayReport | null { + if (state === null) return null; + const packages: SourceOverlayReport["packages"] = []; + for (const instance of state.set.instances) { + const dropped = state.drops.get(instance.key); + // Ambiguous-name drops are reported even when unused: they explain + // why an augmented package compiled from its published JS. + if (dropped === undefined && !used.has(instance.key)) continue; + packages.push({ + key: instance.key, + name: instance.name, + version: instance.version, + dir: instance.overlayDir, + origin: instance.origin, + fallback: dropped ?? null, + }); + } + return { origin: state.set.origin, packages, notes: state.set.notes }; +} + +/* ── the manifest (schema 1) ──────────────────────────────────────────── */ + +/** Walks up from the entry's directory to the nearest + * `.scriptc-pm/manifest.json`. */ +export function findSourceManifest(entryPath: string): string | null { + for (let dir = dirname(resolve(entryPath)); ;) { + const candidate = join(dir, SOURCE_MANIFEST_RELATIVE_PATH); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + /* keep walking */ + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function realDirectory(path: string): string | null { + try { + const real = realpathSync(path); + return statSync(real).isDirectory() ? real.split("\\").join("/").replace(/\/+$/, "") : null; + } catch { + return null; + } +} + +function originOf(source: unknown): string | null { + if (source === null || typeof source !== "object") return null; + const s = source as { repo?: unknown; commit?: unknown; subdir?: unknown }; + if (typeof s.repo !== "string") return null; + const commit = typeof s.commit === "string" ? ` @ ${s.commit.slice(0, 12)}` : ""; + const subdir = typeof s.subdir === "string" && s.subdir !== "" ? ` (${s.subdir})` : ""; + return `${s.repo}${commit}${subdir}`; +} + +/** Reads and validates a schema-1 manifest. Throws an Error naming the + * first problem for an unreadable or malformed manifest; instances whose + * directories are missing are skipped with a note (they compile from + * published JS). */ +export function loadSourceManifest(manifestPath: string): SourceOverlaySet { + const path = resolve(manifestPath); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + throw new Error(`cannot read source manifest ${path}`); + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (e) { + throw new Error( + `source manifest ${path} is not valid JSON (${e instanceof Error ? e.message : String(e)})`, + ); + } + const bad = (what: string): Error => new Error(`source manifest ${path}: ${what}`); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw bad("expected a JSON object"); + } + const doc = raw as Record; + if (doc["schema"] !== 1) { + throw bad(`unsupported schema ${JSON.stringify(doc["schema"])} (this scriptc reads schema 1)`); + } + const lockDigest = doc["lockDigest"]; + if (typeof lockDigest !== "string" || lockDigest === "") { + throw bad("missing lockDigest"); + } + const rawInstances = doc["instances"]; + if (!Array.isArray(rawInstances)) throw bad("missing instances array"); + const instances: SourceOverlayInstance[] = []; + const notes: string[] = []; + const seen = new Set(); + for (let index = 0; index < rawInstances.length; index++) { + const entry: unknown = rawInstances[index]; + if (entry === null || typeof entry !== "object") + throw bad(`instances[${index}] is not an object`); + const e = entry as Record; + for (const field of ["installedDir", "name", "version", "overlayDir"]) { + if (typeof e[field] !== "string" || e[field] === "") { + throw bad(`instances[${index}].${field} must be a non-empty string`); + } + } + const installedDir = e["installedDir"] as string; + const overlayDir = e["overlayDir"] as string; + const name = e["name"] as string; + const version = e["version"] as string; + if (!isAbsolute(installedDir) || !isAbsolute(overlayDir)) { + throw bad(`instances[${index}] directories must be absolute paths`); + } + const exportsField = e["exports"]; + if ( + exportsField === undefined || + exportsField === null || + (typeof exportsField !== "string" && typeof exportsField !== "object") + ) { + throw bad(`instances[${index}].exports must be a string or an object`); + } + const key = realDirectory(installedDir); + if (key === null) { + notes.push(`${name}@${version}: installed directory ${installedDir} does not exist; skipped`); + continue; + } + const realOverlay = realDirectory(overlayDir); + if (realOverlay === null) { + notes.push(`${name}@${version}: overlay directory ${overlayDir} does not exist; skipped`); + continue; + } + if (seen.has(key)) throw bad(`instances[${index}] repeats installed directory ${key}`); + seen.add(key); + instances.push({ + key, + name, + version, + overlayDir: realOverlay, + exports: exportsField, + origin: originOf(e["source"]), + }); + } + const fallbacks = doc["fallbacks"]; + if (Array.isArray(fallbacks)) { + for (const f of fallbacks) { + if (f === null || typeof f !== "object") continue; + const fb = f as { name?: unknown; version?: unknown; reason?: unknown }; + if (typeof fb.name !== "string") continue; + const version = typeof fb.version === "string" ? `@${fb.version}` : ""; + const reason = typeof fb.reason === "string" ? fb.reason : "no reason given"; + notes.push(`${fb.name}${version}: published JS (${reason})`); + } + } + // The lock digest names the dependency and source state the manifest was + // generated from; the bytes digest also covers hand-edited manifests. + const cacheIdentity = `${lockDigest}\0${createHash("sha256").update(text).digest("hex")}`; + return { origin: path, cacheIdentity, instances, notes }; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c3009665a0..5ced3ca435 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -109,7 +109,7 @@ import { writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; -import { provenanceSources } from "./frontend/provenance-registry.js"; +import { sourceOverlayCacheIdentity } from "./frontend/source-overlays.js"; import { clearResolveCaches } from "./frontend/resolve.js"; import { loadFfiProfile, type FfiProfile } from "./ffi/ffi-manifest.js"; import { executableLinkFeatures } from "./backend/executable-features.js"; @@ -290,6 +290,13 @@ export { type ProvenancePackageSource, type ProvenanceSources, } from "./frontend/provenance-registry.js"; +export { + findSourceManifest, + installSourceOverlays, + loadSourceManifest, + sourceOverlayFallbacks, + type SourceOverlaySet, +} from "./frontend/source-overlays.js"; export * as ir from "./ir/api.js"; /** Clang may print every warning from the generated/runtime translation @@ -336,7 +343,7 @@ export async function analyzeCached( ): Promise { entryPath = resolve(entryPath); const root = await prepareBuildCacheRoot(buildCacheRoot()); - if (root === null || provenanceSources() !== null) return analyze(entryPath, opts); + if (root === null) return analyze(entryPath, opts); const directory = join(root, "coverage-v1"); const implementation = await coverageCompilerIdentity(directory); if (implementation === null) return analyze(entryPath, opts); @@ -1001,9 +1008,10 @@ async function compileTracked( }; } const cacheRoot = - outputKind === "exe" && provenanceSources() === null + outputKind === "exe" && sourceOverlayCacheIdentity() !== null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + const sourcesIdentity = sourceOverlayCacheIdentity() ?? undefined; let earlyCacheOptions: EarlyExecutableCacheOptions | null = null; if (outputKind === "exe") { const helperObjectRoute = @@ -1049,6 +1057,7 @@ async function compileTracked( nodeVersion: process.version, implementation: implementation.digest, implementationDependencies: implementation.dependencies, + ...(sourcesIdentity !== undefined ? { sources: sourcesIdentity } : {}), }; } const earlyHit = @@ -1650,7 +1659,8 @@ async function compileLibraryTracked( } const cacheRoot = - provenanceSources() === null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + sourceOverlayCacheIdentity() !== null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + const sourcesIdentity = sourceOverlayCacheIdentity() ?? undefined; const earlyCacheOptions: EarlyLibraryCacheOptions = { profilePath: opts.profilePath, profileBytes: profile.profileBytes, @@ -1663,6 +1673,7 @@ async function compileLibraryTracked( compiler: [process.env["SCRIPTC_CC"] ?? "clang"], nodeVersion: process.version, implementation: await libraryFrontendImplementationFingerprint(), + ...(sourcesIdentity !== undefined ? { sources: sourcesIdentity } : {}), }; const earlyHit = await readEarlyLibraryCache( cacheRoot, diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 1b65024e03..c9fca89093 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -91,6 +91,8 @@ export interface EarlyLibraryCacheOptions { * frontend classification. */ nodeVersion: string; implementation: string; + /** --sources: the overlay set's identity. Omitted without --sources. */ + sources?: string; } export interface EarlyLibraryCacheHit { @@ -154,6 +156,7 @@ function cacheKey(options: EarlyLibraryCacheOptions): string { options.compiler.join("\x1f"), options.nodeVersion, options.implementation, + ...(options.sources !== undefined ? [`sources:${options.sources}`] : []), ]); } diff --git a/packages/compiler/src/native/compiler.test.ts b/packages/compiler/src/native/compiler.test.ts index 8203271ebe..97bf957811 100644 --- a/packages/compiler/src/native/compiler.test.ts +++ b/packages/compiler/src/native/compiler.test.ts @@ -84,6 +84,7 @@ test.each([ sourceTexts: () => new Map(), npmStatic: [], npmImportSites: new Map(), + sourceOverlays: () => null, dispose: () => {}, lower: () => ({ module, @@ -222,6 +223,7 @@ test.each([false, true])( sourceTexts: () => new Map(), npmStatic: [], npmImportSites: new Map(), + sourceOverlays: () => null, dispose, lower: () => ({ module, diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 18635c8c75..11b780c5ce 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -22,7 +22,7 @@ import { type FrontendInputExclusions, trackedReadFile, } from "../frontend/input-tracker.js"; -import { provenanceSources } from "../frontend/provenance-registry.js"; +import { sourceOverlayCacheIdentity } from "../frontend/source-overlays.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; import type { IrModule } from "../ir/ir.js"; @@ -234,13 +234,15 @@ export function prepareNativeExecutable( const exclusions = { outputPaths, outputDirectories: [...directories] }; const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? ""; let key: string | null = null; - if (cache !== null && irInput === "" && provenanceSources() === null) { + const sources = sourceOverlayCacheIdentity(); + if (cache !== null && irInput === "" && sources !== null) { try { key = contentDigest( JSON.stringify({ schema: 1, entry, options, + ...(sources !== undefined ? { sources } : {}), ffi, implementation: implementationIdentity(toolchain), target: toolchain.target.name, @@ -345,12 +347,14 @@ export function prepareNativeLibrary( } const exclusions: FrontendInputExclusions = { outputPaths, outputDirectories: [...directories] }; let key: string | null = null; - if (cache !== null && provenanceSources() === null) { + const sources = sourceOverlayCacheIdentity(); + if (cache !== null && sources !== null) { try { key = contentDigest( JSON.stringify({ schema: 1, kind: "library", + ...(sources !== undefined ? { sources } : {}), entry: profile.entry, profile: profile.profileBytes, options, diff --git a/tests/fixtures/source-overlays/project/emit.ts b/tests/fixtures/source-overlays/project/emit.ts new file mode 100644 index 0000000000..afd0f09a98 --- /dev/null +++ b/tests/fixtures/source-overlays/project/emit.ts @@ -0,0 +1,20 @@ +import mitt, { type Emitter } from "mitt"; + +type Events = { greet: string; count: number }; +const emitter: Emitter = mitt(); +const seen: string[] = []; +const onGreet = (name: string): void => { + seen.push(`hello ${name}`); +}; +emitter.on("greet", onGreet); +emitter.on("count", (n) => seen.push(`count ${n * 2}`)); +emitter.on("*", (type, payload) => seen.push(`* ${String(type)} ${JSON.stringify(payload)}`)); +emitter.emit("greet", "world"); +emitter.emit("count", 21); +emitter.off("greet", onGreet); +emitter.emit("greet", "again"); +console.log(seen.join("\n")); +console.log("handlers", emitter.all.size, emitter.all.get("greet")?.length ?? 0); +emitter.all.clear(); +emitter.emit("count", 1); +console.log("cleared", seen.length); diff --git a/tests/fixtures/source-overlays/project/flags.ts b/tests/fixtures/source-overlays/project/flags.ts new file mode 100644 index 0000000000..3a9cdfa56b --- /dev/null +++ b/tests/fixtures/source-overlays/project/flags.ts @@ -0,0 +1,17 @@ +import { InMemoryProvider, OpenFeature } from "@openfeature/server-sdk"; + +const provider = new InMemoryProvider({ + "new-ui": { variants: { on: true, off: false }, defaultVariant: "on", disabled: false }, + greeting: { + variants: { formal: "Good day", casual: "Hey" }, + defaultVariant: "casual", + disabled: false, + }, +}); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient("demo"); +console.log("new-ui", await client.getBooleanValue("new-ui", false)); +console.log("greeting", await client.getStringValue("greeting", "Hello")); +const details = await client.getStringDetails("missing", "fallback"); +console.log("missing", details.value, details.reason, details.errorCode); +await OpenFeature.close(); diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE new file mode 100644 index 0000000000..96b3dc8fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js new file mode 100644 index 0000000000..cee9551822 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js @@ -0,0 +1,1166 @@ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// src/hooks/hook-data.ts +var MapHookData = class { + constructor() { + this.data = /* @__PURE__ */ new Map(); + } + set(key, value) { + this.data.set(key, value); + } + get(key) { + return this.data.get(key); + } + has(key) { + return this.data.has(key); + } + delete(key) { + return this.data.delete(key); + } + clear() { + this.data.clear(); + } +}; + +// src/evaluation/evaluation.ts +var StandardResolutionReasons = { + /** + * The resolved value is static (no dynamic evaluation). + */ + STATIC: "STATIC", + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + DEFAULT: "DEFAULT", + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + TARGETING_MATCH: "TARGETING_MATCH", + /** + * The resolved value was the result of pseudorandom assignment. + */ + SPLIT: "SPLIT", + /** + * The resolved value was retrieved from cache. + */ + CACHED: "CACHED", + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + DISABLED: "DISABLED", + /** + * The reason for the resolved value could not be determined. + */ + UNKNOWN: "UNKNOWN", + /** + * The resolved value is non-authoritative or possibly out of date. + */ + STALE: "STALE", + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + ERROR: "ERROR" +}; +var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => { + ErrorCode2["PROVIDER_NOT_READY"] = "PROVIDER_NOT_READY"; + ErrorCode2["PROVIDER_FATAL"] = "PROVIDER_FATAL"; + ErrorCode2["FLAG_NOT_FOUND"] = "FLAG_NOT_FOUND"; + ErrorCode2["PARSE_ERROR"] = "PARSE_ERROR"; + ErrorCode2["TYPE_MISMATCH"] = "TYPE_MISMATCH"; + ErrorCode2["TARGETING_KEY_MISSING"] = "TARGETING_KEY_MISSING"; + ErrorCode2["INVALID_CONTEXT"] = "INVALID_CONTEXT"; + ErrorCode2["GENERAL"] = "GENERAL"; + return ErrorCode2; +})(ErrorCode || {}); + +// src/errors/open-feature-error-abstract.ts +var OpenFeatureError = class _OpenFeatureError extends Error { + constructor(message, options) { + super(message); + Object.setPrototypeOf(this, _OpenFeatureError.prototype); + this.name = "OpenFeatureError"; + this.cause = options == null ? void 0 : options.cause; + } +}; + +// src/errors/flag-not-found-error.ts +var FlagNotFoundError = class _FlagNotFoundError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _FlagNotFoundError.prototype); + this.name = "FlagNotFoundError"; + this.code = "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */; + } +}; + +// src/errors/general-error.ts +var GeneralError = class _GeneralError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _GeneralError.prototype); + this.name = "GeneralError"; + this.code = "GENERAL" /* GENERAL */; + } +}; + +// src/errors/invalid-context-error.ts +var InvalidContextError = class _InvalidContextError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _InvalidContextError.prototype); + this.name = "InvalidContextError"; + this.code = "INVALID_CONTEXT" /* INVALID_CONTEXT */; + } +}; + +// src/errors/parse-error.ts +var ParseError = class _ParseError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ParseError.prototype); + this.name = "ParseError"; + this.code = "PARSE_ERROR" /* PARSE_ERROR */; + } +}; + +// src/errors/provider-fatal-error.ts +var ProviderFatalError = class _ProviderFatalError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ProviderFatalError.prototype); + this.name = "ProviderFatalError"; + this.code = "PROVIDER_FATAL" /* PROVIDER_FATAL */; + } +}; + +// src/errors/provider-not-ready-error.ts +var ProviderNotReadyError = class _ProviderNotReadyError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ProviderNotReadyError.prototype); + this.name = "ProviderNotReadyError"; + this.code = "PROVIDER_NOT_READY" /* PROVIDER_NOT_READY */; + } +}; + +// src/errors/targeting-key-missing-error.ts +var TargetingKeyMissingError = class _TargetingKeyMissingError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _TargetingKeyMissingError.prototype); + this.name = "TargetingKeyMissingError"; + this.code = "TARGETING_KEY_MISSING" /* TARGETING_KEY_MISSING */; + } +}; + +// src/errors/type-mismatch-error.ts +var TypeMismatchError = class _TypeMismatchError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _TypeMismatchError.prototype); + this.name = "TypeMismatchError"; + this.code = "TYPE_MISMATCH" /* TYPE_MISMATCH */; + } +}; + +// src/errors/index.ts +var instantiateErrorByErrorCode = (errorCode, message) => { + switch (errorCode) { + case "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */: + return new FlagNotFoundError(message); + case "PARSE_ERROR" /* PARSE_ERROR */: + return new ParseError(message); + case "TYPE_MISMATCH" /* TYPE_MISMATCH */: + return new TypeMismatchError(message); + case "TARGETING_KEY_MISSING" /* TARGETING_KEY_MISSING */: + return new TargetingKeyMissingError(message); + case "INVALID_CONTEXT" /* INVALID_CONTEXT */: + return new InvalidContextError(message); + case "PROVIDER_NOT_READY" /* PROVIDER_NOT_READY */: + return new ProviderNotReadyError(message); + case "PROVIDER_FATAL" /* PROVIDER_FATAL */: + return new ProviderFatalError(message); + default: + return new GeneralError(message); + } +}; + +// src/provider/provider.ts +var ServerProviderStatus = /* @__PURE__ */ ((ServerProviderStatus2) => { + ServerProviderStatus2["NOT_READY"] = "NOT_READY"; + ServerProviderStatus2["READY"] = "READY"; + ServerProviderStatus2["ERROR"] = "ERROR"; + ServerProviderStatus2["STALE"] = "STALE"; + ServerProviderStatus2["FATAL"] = "FATAL"; + return ServerProviderStatus2; +})(ServerProviderStatus || {}); +var ClientProviderStatus = /* @__PURE__ */ ((ClientProviderStatus2) => { + ClientProviderStatus2["NOT_READY"] = "NOT_READY"; + ClientProviderStatus2["READY"] = "READY"; + ClientProviderStatus2["ERROR"] = "ERROR"; + ClientProviderStatus2["STALE"] = "STALE"; + ClientProviderStatus2["FATAL"] = "FATAL"; + ClientProviderStatus2["RECONCILING"] = "RECONCILING"; + return ClientProviderStatus2; +})(ClientProviderStatus || {}); + +// src/provider/multi-provider/errors.ts +var ErrorWithCode = class extends OpenFeatureError { + constructor(code, message) { + super(message); + this.code = code; + } +}; +var AggregateError = class _AggregateError extends GeneralError { + constructor(message, originalErrors) { + super(message); + this.originalErrors = originalErrors; + Object.setPrototypeOf(this, _AggregateError.prototype); + this.name = "AggregateError"; + } +}; +var constructAggregateError = (providerErrors) => { + const errorsWithSource = providerErrors.map(({ providerName, error }) => { + return { source: providerName, error }; + }).flat(); + const firstError = errorsWithSource[0]; + const message = firstError ? `Provider errors occurred: ${firstError.source}: ${firstError.error}` : "Provider errors occurred"; + return new AggregateError(message, errorsWithSource); +}; +var throwAggregateErrorFromPromiseResults = (result, providerEntries) => { + const errors = result.map((r, i) => { + if (r.status === "rejected") { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }).filter((val) => Boolean(val)); + if (errors.length) { + throw constructAggregateError(errors); + } +}; + +// src/provider/multi-provider/status-tracker.ts +var StatusTracker = class { + constructor(events, statusEnum, eventEnum) { + this.events = events; + this.statusEnum = statusEnum; + this.eventEnum = eventEnum; + this.providerStatuses = {}; + } + wrapEventHandler(providerEntry) { + var _a, _b, _c, _d, _e; + const provider = providerEntry.provider; + (_a = provider.events) == null ? void 0 : _a.addHandler(this.eventEnum.Error, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.ERROR, details); + }); + (_b = provider.events) == null ? void 0 : _b.addHandler(this.eventEnum.Stale, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.STALE, details); + }); + (_c = provider.events) == null ? void 0 : _c.addHandler(this.eventEnum.ConfigurationChanged, (details) => { + this.events.emit(this.eventEnum.ConfigurationChanged, details); + }); + (_d = provider.events) == null ? void 0 : _d.addHandler(this.eventEnum.Ready, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.READY, details); + }); + const reconcilingEvent = this.eventEnum.Reconciling; + const reconcilingStatus = this.statusEnum.RECONCILING; + if (reconcilingEvent && reconcilingStatus) { + (_e = provider.events) == null ? void 0 : _e.addHandler(reconcilingEvent, (details) => { + this.changeProviderStatus(providerEntry.name, reconcilingStatus, details); + }); + } + } + providerStatus(name) { + return this.providerStatuses[name]; + } + getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(this.statusEnum.FATAL)) { + return this.statusEnum.FATAL; + } else if (statuses.includes(this.statusEnum.NOT_READY)) { + return this.statusEnum.NOT_READY; + } else if (statuses.includes(this.statusEnum.ERROR)) { + return this.statusEnum.ERROR; + } else if (statuses.includes(this.statusEnum.STALE)) { + return this.statusEnum.STALE; + } else if (this.statusEnum.RECONCILING && statuses.includes(this.statusEnum.RECONCILING)) { + return this.statusEnum.RECONCILING; + } + return this.statusEnum.READY; + } + changeProviderStatus(name, status, details) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === this.statusEnum.FATAL || newStatus === this.statusEnum.ERROR) { + this.events.emit(this.eventEnum.Error, details); + } else if (newStatus === this.statusEnum.STALE) { + this.events.emit(this.eventEnum.Stale, details); + } else if (newStatus === this.statusEnum.READY) { + this.events.emit(this.eventEnum.Ready, details); + } else { + const reconcilingEvent = this.eventEnum.Reconciling; + if (reconcilingEvent && this.statusEnum.RECONCILING && newStatus === this.statusEnum.RECONCILING) { + this.events.emit(reconcilingEvent, details); + } + } + } + } +}; + +// src/provider/multi-provider/strategies/base-evaluation-strategy.ts +var BaseEvaluationStrategy = class { + constructor(statusEnum) { + this.statusEnum = statusEnum; + this.runMode = "sequential"; + } + shouldEvaluateThisProvider(strategyContext, _evalContext) { + if (strategyContext.providerStatus === this.statusEnum.NOT_READY || strategyContext.providerStatus === this.statusEnum.FATAL) { + return false; + } + return true; + } + shouldEvaluateNextProvider(_strategyContext, _context, _result) { + return true; + } + shouldTrackWithThisProvider(strategyContext, _context, _trackingEventName, _trackingEventDetails) { + if (strategyContext.providerStatus === this.statusEnum.NOT_READY || strategyContext.providerStatus === this.statusEnum.FATAL) { + return false; + } + return true; + } + hasError(resolution) { + return "thrownError" in resolution || !!resolution.details.errorCode; + } + hasErrorWithCode(resolution, code) { + var _a; + return "thrownError" in resolution ? ((_a = resolution.thrownError) == null ? void 0 : _a.code) === code : resolution.details.errorCode === code; + } + collectProviderErrors(resolutions) { + var _a; + const errors = []; + for (const resolution of resolutions) { + if ("thrownError" in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, (_a = resolution.details.errorMessage) != null ? _a : "unknown error") + }); + } + } + return { errors }; + } + resolutionToFinalResult(resolution) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +}; + +// src/provider/multi-provider/strategies/first-match-strategy.ts +var FirstMatchStrategy = class extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext, context, result) { + if (this.hasErrorWithCode(result, "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + determineFinalResult(strategyContext, context, resolutions) { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/provider/multi-provider/strategies/first-successful-strategy.ts +var FirstSuccessfulStrategy = class extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext, context, result) { + return this.hasError(result); + } + determineFinalResult(strategyContext, context, resolutions) { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/provider/multi-provider/strategies/comparison-strategy.ts +var ComparisonStrategy = class extends BaseEvaluationStrategy { + constructor(statusEnum, fallbackProvider, onMismatch) { + super(statusEnum); + this.fallbackProvider = fallbackProvider; + this.onMismatch = onMismatch; + this.runMode = "parallel"; + } + determineFinalResult(strategyContext, context, resolutions) { + var _a; + let value; + let fallbackResolution; + let finalResolution; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== "undefined" && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + if (!fallbackResolution) { + throw new GeneralError("Fallback provider not found in resolution results"); + } + if (!finalResolution) { + throw new GeneralError("Final resolution not found in resolution results"); + } + if (mismatch) { + (_a = this.onMismatch) == null ? void 0 : _a.call(this, resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider + }; + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/events/events.ts +var ServerProviderEvents = /* @__PURE__ */ ((ServerProviderEvents2) => { + ServerProviderEvents2["Ready"] = "PROVIDER_READY"; + ServerProviderEvents2["Error"] = "PROVIDER_ERROR"; + ServerProviderEvents2["ConfigurationChanged"] = "PROVIDER_CONFIGURATION_CHANGED"; + ServerProviderEvents2["Stale"] = "PROVIDER_STALE"; + return ServerProviderEvents2; +})(ServerProviderEvents || {}); +var ClientProviderEvents = /* @__PURE__ */ ((ClientProviderEvents2) => { + ClientProviderEvents2["Ready"] = "PROVIDER_READY"; + ClientProviderEvents2["Error"] = "PROVIDER_ERROR"; + ClientProviderEvents2["ConfigurationChanged"] = "PROVIDER_CONFIGURATION_CHANGED"; + ClientProviderEvents2["ContextChanged"] = "PROVIDER_CONTEXT_CHANGED"; + ClientProviderEvents2["Reconciling"] = "PROVIDER_RECONCILING"; + ClientProviderEvents2["Stale"] = "PROVIDER_STALE"; + return ClientProviderEvents2; +})(ClientProviderEvents || {}); + +// src/events/event-utils.ts +var eventStatusMap = { + ["READY" /* READY */]: "PROVIDER_READY" /* Ready */, + ["ERROR" /* ERROR */]: "PROVIDER_ERROR" /* Error */, + ["FATAL" /* FATAL */]: "PROVIDER_ERROR" /* Error */, + ["STALE" /* STALE */]: "PROVIDER_STALE" /* Stale */, + ["RECONCILING" /* RECONCILING */]: "PROVIDER_RECONCILING" /* Reconciling */, + ["NOT_READY" /* NOT_READY */]: void 0 +}; +var statusMatchesEvent = (event, status) => { + return !status && event === "PROVIDER_READY" /* Ready */ || eventStatusMap[status] === event; +}; + +// src/logger/default-logger.ts +var DefaultLogger = class { + error(...args) { + console.error(...args); + } + warn(...args) { + console.warn(...args); + } + info() { + } + debug() { + } +}; + +// src/logger/safe-logger.ts +var LOG_LEVELS = ["error", "warn", "info", "debug"]; +var SafeLogger = class { + constructor(logger) { + this.fallbackLogger = new DefaultLogger(); + try { + for (const level of LOG_LEVELS) { + if (!logger[level] || typeof logger[level] !== "function") { + throw new Error(`The provided logger is missing the ${level} method.`); + } + } + this.logger = logger; + } catch (err) { + console.error(err); + console.error("Falling back to the default logger."); + this.logger = this.fallbackLogger; + } + } + error(...args) { + this.log("error", ...args); + } + warn(...args) { + this.log("warn", ...args); + } + info(...args) { + this.log("info", ...args); + } + debug(...args) { + this.log("debug", ...args); + } + log(level, ...args) { + try { + this.logger[level](...args); + } catch (error) { + this.fallbackLogger[level](...args); + } + } +}; + +// src/events/generic-event-emitter.ts +var GenericEventEmitter = class { + constructor(globalLogger) { + this.globalLogger = globalLogger; + this._handlers = { + ["PROVIDER_CONFIGURATION_CHANGED" /* ConfigurationChanged */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_CONTEXT_CHANGED" /* ContextChanged */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_READY" /* Ready */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_ERROR" /* Error */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_STALE" /* Stale */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_RECONCILING" /* Reconciling */]: /* @__PURE__ */ new WeakMap() + }; + } + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType, context) { + this.eventEmitter.emit(eventType, context); + } + addHandler(eventType, handler) { + const asyncHandler = (details) => __async(this, null, function* () { + var _a; + try { + yield handler(details); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + }); + const existingAsyncHandlers = this._handlers[eventType].get(handler); + this._handlers[eventType].set(handler, [...existingAsyncHandlers || [], asyncHandler]); + this.eventEmitter.on(eventType, asyncHandler); + } + removeHandler(eventType, handler) { + const existingAsyncHandlers = this._handlers[eventType].get(handler); + if (existingAsyncHandlers) { + const removedAsyncHandler = existingAsyncHandlers.pop(); + if (removedAsyncHandler) { + this.eventEmitter.removeListener(eventType, removedAsyncHandler); + } + } + } + removeAllHandlers(eventType) { + if (eventType) { + this.eventEmitter.removeAllListeners(eventType); + } else { + this.eventEmitter.removeAllListeners(); + } + } + getHandlers(eventType) { + return this.eventEmitter.listeners(eventType); + } + setLogger(logger) { + this._eventLogger = new SafeLogger(logger); + return this; + } + get _logger() { + var _a, _b; + return (_b = this._eventLogger) != null ? _b : (_a = this.globalLogger) == null ? void 0 : _a.call(this); + } +}; + +// src/telemetry/attributes.ts +var TelemetryAttribute = { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + KEY: "feature_flag.key", + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + ERROR_CODE: "error.type", + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + ERROR_MESSAGE: "error.message", + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + VARIANT: "feature_flag.result.variant", + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + VALUE: "feature_flag.result.value", + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + CONTEXT_ID: "feature_flag.context.id", + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + REASON: "feature_flag.result.reason", + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + PROVIDER: "feature_flag.provider.name", + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + FLAG_SET_ID: "feature_flag.set.id", + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + VERSION: "feature_flag.version" +}; + +// src/telemetry/flag-metadata.ts +var TelemetryFlagMetadata = { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + CONTEXT_ID: "contextId", + /** + * A logical identifier for the flag set. + */ + FLAG_SET_ID: "flagSetId", + /** + * A version string (format unspecified) for the flag or flag set. + */ + VERSION: "version" +}; + +// src/telemetry/evaluation-event.ts +var FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"; +function createEvaluationEvent(hookContext, evaluationDetails) { + var _a, _b, _c; + const attributes = { + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.REASON]: ((_a = evaluationDetails.reason) != null ? _a : StandardResolutionReasons.UNKNOWN).toLowerCase() + }; + if (evaluationDetails.variant) { + attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant; + } + if (evaluationDetails.value !== null) { + if (typeof evaluationDetails.value !== "object") { + attributes[TelemetryAttribute.VALUE] = evaluationDetails.value; + } else { + try { + attributes[TelemetryAttribute.VALUE] = JSON.stringify(evaluationDetails.value); + } catch (e) { + } + } + } + const contextId = (_b = evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID]) != null ? _b : hookContext.context.targetingKey; + if (contextId) { + attributes[TelemetryAttribute.CONTEXT_ID] = contextId; + } + const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID]; + if (setId) { + attributes[TelemetryAttribute.FLAG_SET_ID] = setId; + } + const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION]; + if (version) { + attributes[TelemetryAttribute.VERSION] = version; + } + if (evaluationDetails.reason === StandardResolutionReasons.ERROR) { + attributes[TelemetryAttribute.ERROR_CODE] = ((_c = evaluationDetails.errorCode) != null ? _c : "GENERAL" /* GENERAL */).toLowerCase(); + if (evaluationDetails.errorMessage) { + attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage; + } + } + return { + name: FLAG_EVALUATION_EVENT_NAME, + attributes + }; +} + +// src/type-guards.ts +function isString(value) { + return typeof value === "string"; +} +function stringOrUndefined(value) { + return isString(value) ? value : void 0; +} +function isObject(value) { + return typeof value === "object"; +} +function objectOrUndefined(value) { + return isObject(value) ? value : void 0; +} + +// src/filter.ts +function isDefined(input) { + return typeof input !== "undefined" && input !== null; +} + +// src/open-feature.ts +var ProviderWrapper = class { + constructor(_provider, _status, _statusEnumType) { + this._provider = _provider; + this._status = _status; + this._pendingContextChanges = 0; + var _a, _b, _c; + (_a = _provider.events) == null ? void 0 : _a.addHandler("PROVIDER_READY" /* Ready */, () => { + this._status = _statusEnumType.READY; + }); + (_b = _provider.events) == null ? void 0 : _b.addHandler("PROVIDER_STALE" /* Stale */, () => { + this._status = _statusEnumType.STALE; + }); + (_c = _provider.events) == null ? void 0 : _c.addHandler("PROVIDER_ERROR" /* Error */, (details) => { + if ((details == null ? void 0 : details.errorCode) === "PROVIDER_FATAL" /* PROVIDER_FATAL */) { + this._status = _statusEnumType.FATAL; + } else { + this._status = _statusEnumType.ERROR; + } + }); + } + get provider() { + return this._provider; + } + set provider(provider) { + this._provider = provider; + } + get status() { + return this._status; + } + set status(status) { + this._status = status; + } + get allContextChangesSettled() { + return this._pendingContextChanges === 0; + } + incrementPendingContextChanges() { + this._pendingContextChanges++; + } + decrementPendingContextChanges() { + this._pendingContextChanges--; + } +}; +var OpenFeatureCommonAPI = class { + constructor(category) { + this._hooks = []; + this._context = {}; + this._logger = new DefaultLogger(); + this._clientEventHandlers = /* @__PURE__ */ new Map(); + this._domainScopedContext = /* @__PURE__ */ new Map(); + this._clientEvents = /* @__PURE__ */ new Map(); + this._runsOn = category; + } + addHooks(...hooks) { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + getHooks() { + return this._hooks; + } + clearHooks() { + this._hooks = []; + return this; + } + setLogger(logger) { + this._logger = new SafeLogger(logger); + return this; + } + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata() { + return this.getProviderMetadata(); + } + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain) { + return this.getProviderForClient(domain).metadata; + } + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType, handler, options) { + [.../* @__PURE__ */ new Map([[void 0, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => { + var _a; + const domain = keyProviderTuple[0]; + const provider = keyProviderTuple[1].provider; + const status = keyProviderTuple[1].status; + const shouldRunNow = statusMatchesEvent(eventType, status); + if (shouldRunNow) { + try { + handler({ domain, providerName: provider.metadata.name }); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + } + }); + this._apiEmitter.addHandler(eventType, handler); + if ((options == null ? void 0 : options.signal) && typeof options.signal.addEventListener === "function") { + options.signal.addEventListener("abort", () => { + this.removeHandler(eventType, handler); + }); + } + } + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType, handler) { + this._apiEmitter.removeHandler(eventType, handler); + } + /** + * Removes all event handlers. + */ + clearHandlers() { + this._apiEmitter.removeAllHandlers(); + } + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType) { + return this._apiEmitter.getHandlers(eventType); + } + setAwaitableProvider(domainOrProvider, providerOrUndefined) { + var _a, _b, _c, _d, _e, _f, _g, _h; + const domain = stringOrUndefined(domainOrProvider); + const provider = (_a = objectOrUndefined(domainOrProvider)) != null ? _a : objectOrUndefined(providerOrUndefined); + if (!provider) { + this._logger.debug("No provider defined, ignoring setProvider call"); + return; + } + const oldProvider = this.getProviderForClient(domain); + const providerName = provider.metadata.name; + if (oldProvider === provider) { + this._logger.debug("Provider is already set, ignoring setProvider call"); + return; + } + if (!provider.runsOn) { + this._logger.debug(`Provider '${provider.metadata.name}' has not defined its intended use.`); + } else if (provider.runsOn !== this._runsOn) { + throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`); + } + const emitters = this.getAssociatedEventEmitters(domain); + let initializationPromise = void 0; + const wrappedProvider = new ProviderWrapper( + provider, + this._statusEnumType.NOT_READY, + this._statusEnumType + ); + if (typeof provider.initialize === "function" && !this.allProviders.includes(provider)) { + initializationPromise = (_e = (_d = (_c = provider.initialize) == null ? void 0 : _c.call(provider, domain ? (_b = this._domainScopedContext.get(domain)) != null ? _b : this._context : this._context)) == null ? void 0 : _d.then(() => { + var _a2; + wrappedProvider.status = this._statusEnumType.READY; + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + }); + (_a2 = this._apiEmitter) == null ? void 0 : _a2.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + })) == null ? void 0 : _e.catch((error) => { + var _a2; + if ((error == null ? void 0 : error.code) === "PROVIDER_FATAL" /* PROVIDER_FATAL */) { + wrappedProvider.status = this._statusEnumType.FATAL; + } else { + wrappedProvider.status = this._statusEnumType.ERROR; + } + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_ERROR" /* Error */, { + clientName: domain, + domain, + providerName, + message: error == null ? void 0 : error.message + }); + }); + (_a2 = this._apiEmitter) == null ? void 0 : _a2.emit("PROVIDER_ERROR" /* Error */, { + clientName: domain, + domain, + providerName, + message: error == null ? void 0 : error.message + }); + throw error; + }); + } else { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + }); + (_f = this._apiEmitter) == null ? void 0 : _f.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + } + if (domain) { + this._domainScopedProviders.set(domain, wrappedProvider); + } else { + this._defaultProvider = wrappedProvider; + } + this.transferListeners(oldProvider, provider, domain, emitters); + if (!this.allProviders.includes(oldProvider)) { + (_h = (_g = oldProvider == null ? void 0 : oldProvider.onClose) == null ? void 0 : _g.call(oldProvider)) == null ? void 0 : _h.catch((err) => { + this._logger.error(`error closing provider: ${err == null ? void 0 : err.message}, ${err == null ? void 0 : err.stack}`); + }); + } + return initializationPromise; + } + getProviderForClient(domain) { + var _a, _b; + if (!domain) { + return this._defaultProvider.provider; + } + return (_b = (_a = this._domainScopedProviders.get(domain)) == null ? void 0 : _a.provider) != null ? _b : this._defaultProvider.provider; + } + buildAndCacheEventEmitterForClient(domain) { + const emitter = this._clientEvents.get(domain); + if (emitter) { + return emitter; + } + const newEmitter = this._createEventEmitter(); + this._clientEvents.set(domain, newEmitter); + const clientProvider = this.getProviderForClient(domain); + Object.values(ClientProviderEvents).forEach( + (eventType) => { + var _a; + return (_a = clientProvider.events) == null ? void 0 : _a.addHandler(eventType, (details) => __async(this, null, function* () { + newEmitter.emit(eventType, __spreadProps(__spreadValues({}, details), { + clientName: domain, + domain, + providerName: clientProvider.metadata.name + })); + })); + } + ); + return newEmitter; + } + getUnboundEmitters() { + const domainScopedProviders = [...this._domainScopedProviders.keys()]; + const eventEmitterNames = [...this._clientEvents.keys()].filter(isDefined); + const unboundEmitterNames = eventEmitterNames.filter((name) => !domainScopedProviders.includes(name)); + return [ + // all unbound, named emitters + ...unboundEmitterNames.map((name) => this._clientEvents.get(name)), + // the default emitter + this._clientEvents.get(void 0) + ].filter(isDefined); + } + getAssociatedEventEmitters(domain) { + return domain ? [this.buildAndCacheEventEmitterForClient(domain)] : this.getUnboundEmitters(); + } + transferListeners(oldProvider, newProvider, domain, emitters) { + var _a; + (_a = this._clientEventHandlers.get(domain)) == null ? void 0 : _a.forEach((eventHandler) => { + var _a2; + return (_a2 = oldProvider.events) == null ? void 0 : _a2.removeHandler(...eventHandler); + }); + const newClientHandlers = Object.values(ClientProviderEvents).map((eventType) => { + const handler = (details) => __async(this, null, function* () { + emitters.forEach((emitter) => { + emitter == null ? void 0 : emitter.emit(eventType, __spreadProps(__spreadValues({}, details), { clientName: domain, domain, providerName: newProvider.metadata.name })); + }); + this._apiEmitter.emit(eventType, __spreadProps(__spreadValues({}, details), { + clientName: domain, + domain, + providerName: newProvider.metadata.name + })); + }); + return [eventType, handler]; + }); + this._clientEventHandlers.set(domain, newClientHandlers); + newClientHandlers.forEach((eventHandler) => { + var _a2; + return (_a2 = newProvider.events) == null ? void 0 : _a2.addHandler(...eventHandler); + }); + } + close() { + return __async(this, null, function* () { + var _a, _b; + try { + yield (_b = (_a = this == null ? void 0 : this._defaultProvider.provider) == null ? void 0 : _a.onClose) == null ? void 0 : _b.call(_a); + } catch (err) { + this.handleShutdownError(this._defaultProvider.provider, err); + } + const wrappers = Array.from(this._domainScopedProviders); + yield Promise.all( + wrappers.map((_0) => __async(this, [_0], function* ([, wrapper]) { + var _a2, _b2; + try { + yield (_b2 = wrapper == null ? void 0 : (_a2 = wrapper.provider).onClose) == null ? void 0 : _b2.call(_a2); + } catch (err) { + this.handleShutdownError(wrapper == null ? void 0 : wrapper.provider, err); + } + })) + ); + }); + } + clearProvidersAndSetDefault(defaultProvider) { + return __async(this, null, function* () { + try { + yield this.close(); + } catch (err) { + this._logger.error("Unable to cleanly close providers. Resetting to the default configuration."); + } finally { + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType + ); + } + }); + } + get allProviders() { + return [ + ...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider), + this._defaultProvider.provider + ]; + } + handleShutdownError(provider, err) { + this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`); + this._logger.error(err == null ? void 0 : err.stack); + } +}; +export { + AggregateError, + ClientProviderEvents as AllProviderEvents, + ClientProviderStatus as AllProviderStatus, + ComparisonStrategy as BaseComparisonStrategy, + BaseEvaluationStrategy, + FirstMatchStrategy as BaseFirstMatchStrategy, + FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy, + ClientProviderEvents, + ClientProviderStatus, + DefaultLogger, + ErrorCode, + ErrorWithCode, + FlagNotFoundError, + GeneralError, + GenericEventEmitter, + InvalidContextError, + LOG_LEVELS, + MapHookData, + OpenFeatureCommonAPI, + OpenFeatureError, + ParseError, + ProviderFatalError, + ProviderNotReadyError, + ProviderWrapper, + SafeLogger, + ServerProviderEvents, + ServerProviderStatus, + StandardResolutionReasons, + StatusTracker, + TargetingKeyMissingError, + TelemetryAttribute, + TelemetryFlagMetadata, + TypeMismatchError, + constructAggregateError, + createEvaluationEvent, + instantiateErrorByErrorCode, + isObject, + isString, + objectOrUndefined, + statusMatchesEvent, + stringOrUndefined, + throwAggregateErrorFromPromiseResults +}; +//# sourceMappingURL=index.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts new file mode 100644 index 0000000000..e578317f55 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts @@ -0,0 +1,1076 @@ +type Metadata = Record; + +/** + * Defines where the library is intended to be run. + */ +type Paradigm = 'server' | 'client'; + +type PrimitiveValue = null | boolean | string | number; +type JsonObject = { + [key: string]: JsonValue; +}; +type JsonArray = JsonValue[]; +/** + * Represents a JSON node value. + */ +type JsonValue = PrimitiveValue | JsonObject | JsonArray; + +type EvaluationContextValue = PrimitiveValue | Date | { + [key: string]: EvaluationContextValue; +} | EvaluationContextValue[]; +/** + * A container for arbitrary contextual data that can be used as a basis for dynamic evaluation + */ +type EvaluationContext = { + /** + * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. + * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. + * Such providers may behave unpredictably if a targeting key is not specified at flag resolution. + */ + targetingKey?: string; +} & Record; +interface ManageContext { + /** + * Access the evaluation context set on the receiver. + * @returns {EvaluationContext} Evaluation context + */ + getContext(): EvaluationContext; + /** + * Sets evaluation context that will be used during flag evaluations + * on this receiver. + * @template T The type of the receiver + * @param {EvaluationContext} context Evaluation context + * @returns {T} The receiver (this object) + */ + setContext(context: EvaluationContext): T; +} + +type FlagValueType = 'boolean' | 'string' | 'number' | 'object'; +/** + * Represents a JSON node value. + */ +type FlagValue = boolean | string | number | JsonValue; +type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); +/** + * A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + * + * This structure is populated by a provider for use by an Application Author (via the Evaluation API) or an Application Integrator (via hooks). + */ +type FlagMetadata = Record; +type ResolutionDetails = { + value: U; + variant?: string; + flagMetadata?: FlagMetadata; + reason?: ResolutionReason; + errorCode?: ErrorCode; + errorMessage?: string; +}; +type EvaluationDetails = { + flagKey: string; + flagMetadata: Readonly; +} & ResolutionDetails; +declare const StandardResolutionReasons: { + /** + * The resolved value is static (no dynamic evaluation). + */ + readonly STATIC: "STATIC"; + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + readonly DEFAULT: "DEFAULT"; + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + readonly TARGETING_MATCH: "TARGETING_MATCH"; + /** + * The resolved value was the result of pseudorandom assignment. + */ + readonly SPLIT: "SPLIT"; + /** + * The resolved value was retrieved from cache. + */ + readonly CACHED: "CACHED"; + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + readonly DISABLED: "DISABLED"; + /** + * The reason for the resolved value could not be determined. + */ + readonly UNKNOWN: "UNKNOWN"; + /** + * The resolved value is non-authoritative or possibly out of date. + */ + readonly STALE: "STALE"; + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + readonly ERROR: "ERROR"; +}; +declare enum ErrorCode { + /** + * The value was resolved before the provider was ready. + */ + PROVIDER_NOT_READY = "PROVIDER_NOT_READY", + /** + * The provider has entered an irrecoverable error state. + */ + PROVIDER_FATAL = "PROVIDER_FATAL", + /** + * The flag could not be found. + */ + FLAG_NOT_FOUND = "FLAG_NOT_FOUND", + /** + * An error was encountered parsing data, such as a flag configuration. + */ + PARSE_ERROR = "PARSE_ERROR", + /** + * The type of the flag value does not match the expected type. + */ + TYPE_MISMATCH = "TYPE_MISMATCH", + /** + * The provider requires a targeting key and one was not provided in the evaluation context. + */ + TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING", + /** + * The evaluation context does not meet provider requirements. + */ + INVALID_CONTEXT = "INVALID_CONTEXT", + /** + * An error with an unspecified code. + */ + GENERAL = "GENERAL" +} + +interface Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; +} +interface ManageLogger { + /** + * Sets a logger on this receiver. This logger supersedes to the global logger + * and is passed to various components in the SDK. + * The logger configured on the global API object will be used for all evaluations, + * unless overridden in a particular client. + * @template T The type of the receiver + * @param {Logger} logger The logger to be used + * @returns {T} The receiver (this object) + */ + setLogger(logger: Logger): T; +} + +declare class DefaultLogger implements Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(): void; + debug(): void; +} + +declare const LOG_LEVELS: Array; +declare class SafeLogger implements Logger { + private readonly logger; + private readonly fallbackLogger; + constructor(logger: Logger); + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; + private log; +} + +/** + * An enumeration of possible events for server-sdk providers. + */ +declare enum ServerProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = "PROVIDER_READY", + /** + * The provider is in an error state. + */ + Error = "PROVIDER_ERROR", + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = "PROVIDER_CONFIGURATION_CHANGED", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = "PROVIDER_STALE" +} +/** + * An enumeration of possible events for web-sdk providers. + */ +declare enum ClientProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = "PROVIDER_READY", + /** + * The provider is in an error state. + */ + Error = "PROVIDER_ERROR", + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = "PROVIDER_CONFIGURATION_CHANGED", + /** + * The context associated with the provider has changed, and the provider has reconciled it's associated state. + */ + ContextChanged = "PROVIDER_CONTEXT_CHANGED", + /** + * The context associated with the provider has changed, and the provider has not yet reconciled its associated state. + */ + Reconciling = "PROVIDER_RECONCILING", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = "PROVIDER_STALE" +} + +/** + * A type representing any possible ProviderEvent (server or client side). + * In most cases, you probably want to import `ProviderEvents` from the respective SDK. + */ +type AnyProviderEvent = ServerProviderEvents | ClientProviderEvents; + +type EventMetadata = { + [key: string]: string | boolean | number; +}; +type CommonEventDetails = { + readonly providerName: string; + /** + * @deprecated alias of "domain", use domain instead + */ + readonly clientName?: string; + readonly domain?: string; +}; +type CommonEventProps = { + readonly message?: string; + readonly metadata?: EventMetadata; +}; +type ReadyEvent = CommonEventProps; +type ErrorEvent = CommonEventProps; +type StaleEvent = CommonEventProps; +type ReconcilingEvent = CommonEventProps & { + readonly errorCode: ErrorCode; +}; +type ConfigChangeEvent = CommonEventProps & { + readonly flagsChanged?: string[]; +}; +type ServerEventMap = { + [ServerProviderEvents.Ready]: ReadyEvent; + [ServerProviderEvents.Error]: ErrorEvent; + [ServerProviderEvents.Stale]: StaleEvent; + [ServerProviderEvents.ConfigurationChanged]: ConfigChangeEvent; +}; +type ClientEventMap = { + [ClientProviderEvents.Ready]: ReadyEvent; + [ClientProviderEvents.Error]: ErrorEvent; + [ClientProviderEvents.Stale]: StaleEvent; + [ClientProviderEvents.ConfigurationChanged]: ConfigChangeEvent; + [ClientProviderEvents.Reconciling]: CommonEventProps; + [ClientProviderEvents.ContextChanged]: CommonEventProps; +}; +type ServerNotChangeEvents = ServerProviderEvents.Ready | ServerProviderEvents.Error | ServerProviderEvents.Stale; +type ClientNotChangeEvents = ClientProviderEvents.Ready | ClientProviderEvents.Error | ClientProviderEvents.Stale | ClientProviderEvents.ContextChanged | ClientProviderEvents.Reconciling; +type NotChangeEvents = ServerNotChangeEvents | ClientNotChangeEvents; +type EventContext = Record, T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents> = (T extends ClientProviderEvents ? ClientEventMap[T] : T extends ServerProviderEvents ? ServerEventMap[T] : never) & U; +type EventDetails = EventContext, T> & CommonEventDetails; +type EventHandler = (eventDetails?: EventDetails) => Promise | unknown; +type EventOptions = { + signal?: AbortSignal; +}; +interface Eventing { + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * @param eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T extends ClientProviderEvents ? ClientProviderEvents.ConfigurationChanged : ServerProviderEvents.ConfigurationChanged, handler: EventHandler, options?: EventOptions): void; + addHandler(eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents, handler: EventHandler, options?: EventOptions): void; + addHandler(eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents, handler: EventHandler, options?: EventOptions): void; + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; +} + +/** + * Event emitter to be optionally implemented by providers. + * Implemented by @see OpenFeatureEventEmitter. + */ +interface ProviderEventEmitter = Record> extends ManageLogger> { + emit(eventType: E, context?: EventContext): void; + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeAllHandlers(eventType?: AnyProviderEvent): void; + getHandlers(eventType: AnyProviderEvent): EventHandler[]; +} + +/** + * Returns true if the provider's status corresponds to the event. + * If the provider's status is not defined, it matches READY. + * @param {AnyProviderEvent} event event to match + * @param {ClientProviderStatus | ServerProviderStatus} status status of provider + * @returns {boolean} boolean indicating if the provider status corresponds to the event. + */ +declare const statusMatchesEvent: (event: T, status?: ClientProviderStatus | ServerProviderStatus) => boolean; + +/** + * The GenericEventEmitter should only be used within the SDK. It supports additional properties that can be included + * in the event details. + */ +declare abstract class GenericEventEmitter = Record> implements ProviderEventEmitter, ManageLogger> { + private readonly globalLogger?; + protected abstract readonly eventEmitter: PlatformEventEmitter; + private readonly _handlers; + private _eventLogger?; + constructor(globalLogger?: (() => Logger) | undefined); + emit(eventType: E, context?: EventContext): void; + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeAllHandlers(eventType?: AnyProviderEvent): void; + getHandlers(eventType: AnyProviderEvent): EventHandler[]; + setLogger(logger: Logger): this; + protected get _logger(): Logger | undefined; +} +/** + * This is an un-exported type that corresponds to NodeJS.EventEmitter. + * We can't use that type here, because this module is used in both the browser, and the server. + * In the server, node (or whatever server runtime) provides an implementation for this. + * In the browser, we bundle in the popular 'EventEmitter3' package, which is a polyfill of NodeJS.EventEmitter. + */ +interface PlatformEventEmitter { + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + on(eventName: string | symbol, listener: (...args: any[]) => void): this; + once(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + off(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeAllListeners(event?: string | symbol): this; + listeners(eventName: string | symbol): Function[]; + emit(eventName: string | symbol, ...args: any[]): boolean; + listenerCount(eventName: string | symbol, listener?: Function): number; + eventNames(): Array; +} + +type TrackingEventValue = PrimitiveValue | Date | { + [key: string]: TrackingEventValue; +} | TrackingEventValue[]; +/** + * A container for arbitrary data that can relevant to tracking events. + */ +type TrackingEventDetails = { + /** + * A numeric value associated with this event. + */ + value?: number; +} & Record; + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +declare enum ServerProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = "NOT_READY", + /** + * The provider is ready to resolve flags. + */ + READY = "READY", + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = "ERROR", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = "STALE", + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = "FATAL" +} +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +declare enum ClientProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = "NOT_READY", + /** + * The provider is ready to resolve flags. + */ + READY = "READY", + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = "ERROR", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = "STALE", + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = "FATAL", + /** + * The provider is reconciling its state with a context change. + */ + RECONCILING = "RECONCILING" +} + +/** + * Static data about the provider. + */ +interface ProviderMetadata extends Readonly { + readonly name: string; +} +interface CommonProvider { + readonly metadata: ProviderMetadata; + /** + * Represents where the provider is intended to be run. If defined, + * the SDK will enforce that the defined paradigm at runtime. + */ + readonly runsOn?: Paradigm; + /** + * @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field. + * Returns a representation of the current readiness of the provider. + * + * _Providers which do not implement this method are assumed to be ready immediately._ + */ + readonly status?: S; + /** + * An event emitter for ProviderEvents. + * @see ProviderEvents + */ + events?: ProviderEventEmitter; + /** + * A function used to shut down the provider. + * Called when this provider is replaced with a new one, or when the OpenFeature is shut down. + */ + onClose?(): Promise; + /** + * A function used to setup the provider. + * Called by the SDK after the provider is set if the provider's status is NOT_READY. + * When the returned promise resolves, the SDK fires the ProviderEvents.Ready event. + * If the returned promise rejects, the SDK fires the ProviderEvents.Error event. + * Use this function to perform any context-dependent setup within the provider. + * @param context + */ + initialize?(context?: EvaluationContext): Promise; + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName + * @param context + * @param trackingEventDetails + */ + track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; +} + +type ProviderEntryInput = { + provider: TProvider; + name?: string; +}; +type RegisteredProvider = Required>; + +/** + * Error Options were added in ES2022. Manually adding the type so that an + * earlier target can be used. + */ +type ErrorOptions = { + cause?: unknown; +}; +declare abstract class OpenFeatureError extends Error { + abstract code: ErrorCode; + cause?: unknown; + constructor(message?: string, options?: ErrorOptions); +} + +declare class FlagNotFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class GeneralError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class InvalidContextError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ParseError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ProviderFatalError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ProviderNotReadyError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class TargetingKeyMissingError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class TypeMismatchError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare const instantiateErrorByErrorCode: (errorCode: ErrorCode, message?: string) => OpenFeatureError; + +declare class ErrorWithCode extends OpenFeatureError { + code: ErrorCode; + constructor(code: ErrorCode, message: string); +} +declare class AggregateError extends GeneralError { + originalErrors: { + source: string; + error: unknown; + }[]; + constructor(message: string, originalErrors: { + source: string; + error: unknown; + }[]); +} +declare const constructAggregateError: (providerErrors: { + error: unknown; + providerName: string; +}[]) => AggregateError; +declare const throwAggregateErrorFromPromiseResults: (result: PromiseSettledResult[], providerEntries: Required>[]) => void; + +/** + * Interface for the provider events enum that StatusTracker expects. + * Must have at least Error, Stale, ConfigurationChanged, and Ready events. + * Reconciling is optional (web only). + */ +type ProviderEventsEnum = { + Error: T; + Stale: T; + ConfigurationChanged: T; + Ready: T; + Reconciling?: T; +}; +/** + * Interface for the provider status enum that StatusTracker expects. + */ +type ProviderStatusEnum = { + NOT_READY: T; + READY: T; + ERROR: T; + STALE: T; + FATAL: T; + RECONCILING?: T; +}; +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +declare class StatusTracker; +}> { + private events; + private statusEnum; + private eventEnum; + private readonly providerStatuses; + constructor(events: ProviderEventEmitter, statusEnum: ProviderStatusEnum, eventEnum: ProviderEventsEnum); + wrapEventHandler(providerEntry: RegisteredProvider): void; + providerStatus(name: string): TProviderStatus; + private getStatusFromProviderStatuses; + private changeProviderStatus; +} + +type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; +type BaseStrategyProviderContext = { + provider: TProvider; + providerName: string; + providerStatus: TProviderStatus; +}; +type BaseStrategyPerProviderContext = StrategyEvaluationContext & BaseStrategyProviderContext; +type BaseProviderResolutionResultBase = { + provider: TProvider; + providerName: string; +}; +type BaseProviderResolutionSuccessResult = BaseProviderResolutionResultBase & { + details: ResolutionDetails; +}; +type BaseProviderResolutionErrorResult<_TProviderStatus, TProvider> = BaseProviderResolutionResultBase & { + thrownError: unknown; +}; +type BaseProviderResolutionResult = BaseProviderResolutionSuccessResult | BaseProviderResolutionErrorResult; +type BaseFinalResult = { + details?: ResolutionDetails; + provider?: TProvider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; +/** + * Base evaluation strategy for multi-provider flag resolution. + * + * This class is intended to be extended by concrete strategies and is not + * directly usable on its own. Implementations must provide a + * `determineFinalResult` method that takes the per-provider results and + * determines the final flag resolution outcome. + * + * The base class also provides default implementations for + * `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` that can be + * used as-is or overridden by subclasses as needed. + */ +declare abstract class BaseEvaluationStrategy { + protected statusEnum: Record; + runMode: 'parallel' | 'sequential'; + constructor(statusEnum: Record); + shouldEvaluateThisProvider(strategyContext: BaseStrategyPerProviderContext, _evalContext?: EvaluationContext): boolean; + shouldEvaluateNextProvider(_strategyContext?: BaseStrategyPerProviderContext, _context?: EvaluationContext, _result?: BaseProviderResolutionResult): boolean; + shouldTrackWithThisProvider(strategyContext: BaseStrategyProviderContext, _context?: EvaluationContext, _trackingEventName?: string, _trackingEventDetails?: TrackingEventDetails): boolean; + abstract determineFinalResult(strategyContext: StrategyEvaluationContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; + protected hasError(resolution: BaseProviderResolutionResult): resolution is BaseProviderResolutionErrorResult | (BaseProviderResolutionSuccessResult & { + details: ResolutionDetails & { + errorCode: ErrorCode; + }; + }); + protected hasErrorWithCode(resolution: BaseProviderResolutionResult, code: ErrorCode): boolean; + protected collectProviderErrors(resolutions: BaseProviderResolutionResult[]): BaseFinalResult; + protected resolutionToFinalResult(resolution: BaseProviderResolutionSuccessResult): { + details: ResolutionDetails; + provider: TProvider; + providerName: string; + }; +} + +declare class FirstMatchStrategy extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, result: BaseProviderResolutionResult): boolean; + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +declare class FirstSuccessfulStrategy extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, result: BaseProviderResolutionResult): boolean; + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +declare class ComparisonStrategy extends BaseEvaluationStrategy { + private fallbackProvider; + private onMismatch?; + runMode: "parallel"; + constructor(statusEnum: Record, fallbackProvider: TProvider, onMismatch?: ((resolutions: BaseProviderResolutionResult[]) => void) | undefined); + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +interface ClientMetadata { + /** + * @deprecated alias of "domain", use domain instead + */ + readonly name?: string; + readonly domain?: string; + readonly version?: string; + readonly providerMetadata: ProviderMetadata; +} + +/** + * A mutable data structure for hooks to maintain state across their lifecycle. + * Each hook instance gets its own isolated data store that persists for the + * duration of a single flag evaluation. + * @template TData - A record type that defines the shape of the stored data + */ +interface HookData> { + /** + * Sets a value in the hook data store. + * @param key The key to store the value under + * @param value The value to store + */ + set(key: K, value: TData[K]): void; + set(key: string, value: unknown): void; + /** + * Gets a value from the hook data store. + * @param key The key to retrieve the value for + * @returns The stored value, or undefined if not found + */ + get(key: K): TData[K] | undefined; + get(key: string): unknown; + /** + * Checks if a key exists in the hook data store. + * @param key The key to check + * @returns True if the key exists, false otherwise + */ + has(key: K): boolean; + has(key: string): boolean; + /** + * Deletes a value from the hook data store. + * @param key The key to delete + * @returns True if the key was deleted, false if it didn't exist + */ + delete(key: K): boolean; + delete(key: string): boolean; + /** + * Clears all values from the hook data store. + */ + clear(): void; +} +/** + * Default implementation of HookData using a Map. + * @template TData - A record type that defines the shape of the stored data + */ +declare class MapHookData> implements HookData { + private readonly data; + set(key: K, value: TData[K]): void; + get(key: K): TData[K] | undefined; + has(key: K): boolean; + delete(key: K): boolean; + clear(): void; +} + +type HookHints = Readonly>; +interface HookContext> { + readonly flagKey: string; + readonly defaultValue: T; + readonly flagValueType: FlagValueType; + readonly context: Readonly; + readonly clientMetadata: ClientMetadata; + readonly providerMetadata: ProviderMetadata; + readonly logger: Logger; + readonly hookData: HookData; +} +interface BeforeHookContext> extends HookContext { + context: EvaluationContext; +} + +interface BaseHook, BeforeHookReturn = unknown, HooksReturn = unknown> { + /** + * Runs before flag values are resolved from the provider. + * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. + * @param hookContext + * @param hookHints + */ + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + /** + * Runs after flag values are successfully resolved from the provider. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + after?(hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): HooksReturn; + /** + * Runs in the event of an unhandled error or promise rejection during flag resolution, or any attached hooks. + * @param hookContext + * @param error + * @param hookHints + */ + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; + /** + * Runs after all other hook stages, regardless of success or error. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + finally?(hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): HooksReturn; +} + +interface EvaluationLifeCycle { + /** + * Adds hooks that will run during flag evaluations on this receiver. + * Hooks are executed in the order they were registered. Adding additional hooks + * will not remove existing hooks. + * Hooks registered on the global API object run with all evaluations. + * Hooks registered on the client run with all evaluations on that client. + * @template T The type of the receiver + * @param {BaseHook[]} hooks A list of hooks that should always run + * @returns {T} The receiver (this object) + */ + addHooks(...hooks: BaseHook[]): T; + /** + * Access all the hooks that are registered on this receiver. + * @returns {BaseHook[]} A list of the client hooks + */ + getHooks(): BaseHook[]; + /** + * Clears all the hooks that are registered on this receiver. + * @template T The type of the receiver + * @returns {T} The receiver (this object) + */ + clearHooks(): T; +} + +/** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ +declare const TelemetryAttribute: { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + readonly KEY: "feature_flag.key"; + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + readonly ERROR_CODE: "error.type"; + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + readonly ERROR_MESSAGE: "error.message"; + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + readonly VARIANT: "feature_flag.result.variant"; + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + readonly VALUE: "feature_flag.result.value"; + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + readonly CONTEXT_ID: "feature_flag.context.id"; + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + readonly REASON: "feature_flag.result.reason"; + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + readonly PROVIDER: "feature_flag.provider.name"; + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + readonly FLAG_SET_ID: "feature_flag.set.id"; + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + readonly VERSION: "feature_flag.version"; +}; + +/** + * Well-known flag metadata attributes for telemetry events. + * @see https://openfeature.dev/specification/appendix-d#flag-metadata + */ +declare const TelemetryFlagMetadata: { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + readonly CONTEXT_ID: "contextId"; + /** + * A logical identifier for the flag set. + */ + readonly FLAG_SET_ID: "flagSetId"; + /** + * A version string (format unspecified) for the flag or flag set. + */ + readonly VERSION: "version"; +}; + +/** + * Attribute types for OpenTelemetry. + * @see https://github.com/open-telemetry/opentelemetry-js/blob/fbbce6e1c0de86e4c504b5788d876fae4d3bc254/api/src/common/Attributes.ts#L35 + */ +declare type AttributeValue = string | number | boolean | string[] | number[] | boolean[]; +type EvaluationEvent = { + /** + * The name of the feature flag evaluation event. + */ + name: string; + /** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @experimental The attributes are subject to change. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ + attributes: Record; +}; +/** + * Returns an OpenTelemetry compliant event for flag evaluation. + * @param {HookContext} hookContext Contextual information about the flag evaluation + * @param {EvaluationDetails} evaluationDetails The details of the flag evaluation + * @returns {EvaluationEvent} An evaluation event object containing the event name and attributes + */ +declare function createEvaluationEvent(hookContext: Readonly>, evaluationDetails: EvaluationDetails): EvaluationEvent; + +/** + * Checks whether the parameter is a string. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is a string + */ +declare function isString(value: unknown): value is string; +/** + * Returns the parameter if it is a string, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {string|undefined} The parameter if it is a string, otherwise undefined + */ +declare function stringOrUndefined(value: unknown): string | undefined; +/** + * Checks whether the parameter is an object. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is an object + */ +declare function isObject(value: unknown): value is T; +/** + * Returns the parameter if it is an object, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {object|undefined} The parameter if it is an object, otherwise undefined + */ +declare function objectOrUndefined(value: unknown): T | undefined; + +type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; +/** + * A provider and its current status. + * For internal use only. + */ +declare class ProviderWrapper

, S extends AnyProviderStatus> { + private _provider; + private _status; + private _pendingContextChanges; + constructor(_provider: P, _status: S, _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus); + get provider(): P; + set provider(provider: P); + get status(): S; + set status(status: S); + get allContextChangesSettled(): boolean; + incrementPendingContextChanges(): void; + decrementPendingContextChanges(): void; +} +declare abstract class OpenFeatureCommonAPI = CommonProvider, H extends BaseHook = BaseHook> implements Eventing, EvaluationLifeCycle>, ManageLogger> { + protected abstract readonly _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus; + protected abstract _createEventEmitter(): GenericEventEmitter; + protected abstract _defaultProvider: ProviderWrapper; + protected abstract readonly _domainScopedProviders: Map>; + protected abstract readonly _apiEmitter: GenericEventEmitter; + protected _hooks: H[]; + protected _context: EvaluationContext; + protected _logger: Logger; + private readonly _clientEventHandlers; + protected _domainScopedContext: Map; + protected _clientEvents: Map>; + protected _runsOn: Paradigm; + constructor(category: Paradigm); + addHooks(...hooks: H[]): this; + getHooks(): H[]; + clearHooks(): this; + setLogger(logger: Logger): this; + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata(): ProviderMetadata; + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain?: string): ProviderMetadata; + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T, handler: EventHandler, options?: EventOptions): void; + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + /** + * Removes all event handlers. + */ + clearHandlers(): void; + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; + abstract setProviderAndWait(clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, contextOrUndefined?: EvaluationContext): Promise; + abstract setProvider(clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, contextOrUndefined?: EvaluationContext): this; + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void; + protected getProviderForClient(domain?: string): P; + protected buildAndCacheEventEmitterForClient(domain?: string): GenericEventEmitter; + private getUnboundEmitters; + protected getAssociatedEventEmitters(domain: string | undefined): GenericEventEmitter>[]; + private transferListeners; + close(): Promise; + protected clearProvidersAndSetDefault(defaultProvider: P): Promise; + private get allProviders(); + private handleShutdownError; +} + +export { AggregateError, ClientProviderEvents as AllProviderEvents, ClientProviderStatus as AllProviderStatus, ComparisonStrategy as BaseComparisonStrategy, BaseEvaluationStrategy, FirstMatchStrategy as BaseFirstMatchStrategy, FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy, ClientProviderEvents, ClientProviderStatus, DefaultLogger, ErrorCode, ErrorWithCode, FlagNotFoundError, GeneralError, GenericEventEmitter, InvalidContextError, LOG_LEVELS, MapHookData, OpenFeatureCommonAPI, OpenFeatureError, ParseError, ProviderFatalError, ProviderNotReadyError, ProviderWrapper, SafeLogger, ServerProviderEvents, ServerProviderStatus, StandardResolutionReasons, StatusTracker, TargetingKeyMissingError, TelemetryAttribute, TelemetryFlagMetadata, TypeMismatchError, constructAggregateError, createEvaluationEvent, instantiateErrorByErrorCode, isObject, isString, objectOrUndefined, statusMatchesEvent, stringOrUndefined, throwAggregateErrorFromPromiseResults }; +export type { AnyProviderEvent, AttributeValue, BaseFinalResult, BaseHook, BaseProviderResolutionErrorResult, BaseProviderResolutionResult, BaseProviderResolutionSuccessResult, BaseStrategyPerProviderContext, BaseStrategyProviderContext, BeforeHookContext, ClientMetadata, CommonEventDetails, CommonProvider, ConfigChangeEvent, ErrorEvent, EvaluationContext, EvaluationContextValue, EvaluationDetails, EvaluationLifeCycle, EventContext, EventDetails, EventHandler, EventMetadata, EventOptions, Eventing, FlagMetadata, FlagValue, FlagValueType, HookContext, HookData, HookHints, JsonArray, JsonObject, JsonValue, Logger, ManageContext, ManageLogger, Metadata, NotChangeEvents, Paradigm, PrimitiveValue, ProviderEntryInput, ProviderEventEmitter, ProviderMetadata, ReadyEvent, ReconcilingEvent, RegisteredProvider, ResolutionDetails, ResolutionReason, StaleEvent, StrategyEvaluationContext, TrackingEventDetails, TrackingEventValue }; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json new file mode 100644 index 0000000000..daf79712eb --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json @@ -0,0 +1,49 @@ +{ + "name": "@openfeature/core", + "version": "1.9.2", + "description": "Shared OpenFeature JS components (server and web)", + "main": "./dist/cjs/index.js", + "files": [ + "dist/" + ], + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "types": "./dist/types.d.ts", + "devDependencies": {}, + "scripts": { + "test": "jest --verbose", + "lint": "eslint ./", + "lint:fix": "eslint ./ --fix", + "clean": "shx rm -rf ./dist", + "type": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly", + "build:esm": "esbuild src/index.ts --bundle --external:events --sourcemap --target=es2015 --format=esm --outfile=./dist/esm/index.js --analyze", + "build:cjs": "esbuild src/index.ts --bundle --external:events --sourcemap --target=es2015 --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:rollup-types": "rollup -c ../../rollup.config.mjs", + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types", + "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", + "current-version": "echo $npm_package_version", + "prepack": "shx cp ./../../LICENSE ./LICENSE", + "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi", + "version": "echo $npm_package_version" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-feature/js-sdk.git" + }, + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-feature/js-sdk/issues" + }, + "homepage": "https://github.com/open-feature/js-sdk#readme", + "keywords": [ + "openfeature", + "feature", + "flags", + "toggles" + ] +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core new file mode 120000 index 0000000000..44065895c2 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core @@ -0,0 +1 @@ +../../../@openfeature+core@1.9.2/node_modules/@openfeature/core \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js new file mode 100644 index 0000000000..1640ba6626 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js @@ -0,0 +1,634 @@ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// src/provider/provider.ts +import { + ServerProviderStatus +} from "@openfeature/core"; + +// src/provider/no-op-provider.ts +var REASON_NO_OP = "No-op"; +var NoopFeatureProvider = class { + constructor() { + this.metadata = { + name: "No-op Provider" + }; + } + resolveBooleanEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveStringEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveNumberEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveObjectEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + noOp(defaultValue) { + return Promise.resolve({ + value: defaultValue, + reason: REASON_NO_OP + }); + } +}; +var NOOP_PROVIDER = new NoopFeatureProvider(); + +// src/provider/in-memory-provider/in-memory-provider.ts +import { + FlagNotFoundError, + GeneralError, + OpenFeatureError as OpenFeatureError2, + StandardResolutionReasons, + TypeMismatchError +} from "@openfeature/core"; + +// src/provider/in-memory-provider/variant-not-found-error.ts +import { ErrorCode, OpenFeatureError } from "@openfeature/core"; +var VariantFoundError = class _VariantFoundError extends OpenFeatureError { + constructor(message) { + super(message); + Object.setPrototypeOf(this, _VariantFoundError.prototype); + this.name = "VariantFoundError"; + this.code = ErrorCode.GENERAL; + } +}; + +// src/provider/in-memory-provider/in-memory-provider.ts +var InMemoryProvider = class { + constructor(flagConfiguration = {}) { + this.events = new OpenFeatureEventEmitter(); + this.runsOn = "server"; + this.metadata = { + name: "in-memory" + }; + this._flagConfiguration = __spreadValues({}, flagConfiguration); + } + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration).filter(([key, value]) => this._flagConfiguration[key] !== value).map(([key]) => key); + this._flagConfiguration = __spreadValues({}, flagConfiguration); + this.events.emit(ServerProviderEvents.ConfigurationChanged, { flagsChanged }); + } + resolveBooleanEvaluation(flagKey, defaultValue, context, logger) { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + resolveNumberEvaluation(flagKey, defaultValue, context, logger) { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + resolveStringEvaluation(flagKey, defaultValue, context, logger) { + return __async(this, null, function* () { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + }); + } + resolveObjectEvaluation(flagKey, defaultValue, context, logger) { + return __async(this, null, function* () { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + }); + } + resolveFlagWithReason(flagKey, defaultValue, ctx, logger) { + return __async(this, null, function* () { + try { + const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger); + if (typeof (resolutionResult == null ? void 0 : resolutionResult.value) != typeof defaultValue) { + throw new TypeMismatchError(); + } + return resolutionResult; + } catch (error) { + if (!(error instanceof OpenFeatureError2)) { + throw new GeneralError((error == null ? void 0 : error.message) || "unknown error"); + } + throw error; + } + }); + } + lookupFlagValue(flagKey, defaultValue, ctx, logger) { + var _a; + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger == null ? void 0 : logger.debug(message); + throw new FlagNotFoundError(message); + } + const flagSpec = this._flagConfiguration[flagKey]; + if (flagSpec.disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + const isContextEval = ctx && (flagSpec == null ? void 0 : flagSpec.contextEvaluator); + const variant = isContextEval ? (_a = flagSpec.contextEvaluator) == null ? void 0 : _a.call(flagSpec, ctx) : flagSpec.defaultVariant; + const value = variant && (flagSpec == null ? void 0 : flagSpec.variants[variant]); + if (value === void 0) { + const message = `no value associated with variant ${variant}`; + logger == null ? void 0 : logger.error(message); + throw new VariantFoundError(message); + } + return __spreadProps(__spreadValues({ + value + }, variant && { variant }), { + reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC + }); + } +}; + +// src/open-feature.ts +import { + OpenFeatureCommonAPI, + ProviderWrapper, + objectOrUndefined, + stringOrUndefined +} from "@openfeature/core"; + +// src/client/internal/open-feature-client.ts +import { + ErrorCode as ErrorCode2, + ProviderFatalError, + ProviderNotReadyError, + SafeLogger, + StandardResolutionReasons as StandardResolutionReasons2, + instantiateErrorByErrorCode, + statusMatchesEvent +} from "@openfeature/core"; +var OpenFeatureClient = class { + constructor(providerAccessor, providerStatusAccessor, emitterAccessor, apiContextAccessor, apiHooksAccessor, transactionContextAccessor, globalLogger, options, context = {}) { + this.providerAccessor = providerAccessor; + this.providerStatusAccessor = providerStatusAccessor; + this.emitterAccessor = emitterAccessor; + this.apiContextAccessor = apiContextAccessor; + this.apiHooksAccessor = apiHooksAccessor; + this.transactionContextAccessor = transactionContextAccessor; + this.globalLogger = globalLogger; + this.options = options; + this._hooks = []; + this._context = context; + } + get metadata() { + var _a, _b; + return { + // Use domain if name is not provided + name: (_a = this.options.domain) != null ? _a : this.options.name, + domain: (_b = this.options.domain) != null ? _b : this.options.name, + version: this.options.version, + providerMetadata: this.providerAccessor().metadata + }; + } + get providerStatus() { + return this.providerStatusAccessor(); + } + addHandler(eventType, handler, options) { + var _a; + this.emitterAccessor().addHandler(eventType, handler); + const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus); + if (shouldRunNow) { + try { + handler({ + clientName: this.metadata.name, + domain: this.metadata.domain, + providerName: this._provider.metadata.name + }); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + } + if ((options == null ? void 0 : options.signal) && typeof options.signal.addEventListener === "function") { + options.signal.addEventListener("abort", () => { + this.removeHandler(eventType, handler); + }); + } + } + removeHandler(eventType, handler) { + this.emitterAccessor().removeHandler(eventType, handler); + } + getHandlers(eventType) { + return this.emitterAccessor().getHandlers(eventType); + } + setLogger(logger) { + this._clientLogger = new SafeLogger(logger); + return this; + } + setContext(context) { + this._context = context; + return this; + } + getContext() { + return this._context; + } + addHooks(...hooks) { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + getHooks() { + return this._hooks; + } + clearHooks() { + this._hooks = []; + return this; + } + getBooleanValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getBooleanDetails(flagKey, defaultValue, context, options)).value; + }); + } + getBooleanDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + this._provider.resolveBooleanEvaluation, + defaultValue, + "boolean", + context, + options + ); + } + getStringValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getStringDetails(flagKey, defaultValue, context, options)).value; + }); + } + getStringDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + // this isolates providers from our restricted string generic argument. + this._provider.resolveStringEvaluation, + defaultValue, + "string", + context, + options + ); + } + getNumberValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getNumberDetails(flagKey, defaultValue, context, options)).value; + }); + } + getNumberDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + // this isolates providers from our restricted number generic argument. + this._provider.resolveNumberEvaluation, + defaultValue, + "number", + context, + options + ); + } + getObjectValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getObjectDetails(flagKey, defaultValue, context, options)).value; + }); + } + getObjectDetails(flagKey, defaultValue, context, options) { + return this.evaluate(flagKey, this._provider.resolveObjectEvaluation, defaultValue, "object", context, options); + } + track(occurrenceKey, context = {}, occurrenceDetails = {}) { + var _a, _b; + try { + this.shortCircuitIfNotReady(); + if (typeof this._provider.track === "function") { + const frozenContext = Object.freeze(this.mergeContexts(context)); + return (_b = (_a = this._provider).track) == null ? void 0 : _b.call(_a, occurrenceKey, frozenContext, occurrenceDetails); + } else { + this._logger.debug("Provider does not support the track function; will no-op."); + } + } catch (err) { + this._logger.debug("Error recording tracking event.", err); + } + } + evaluate(_0, _1, _2, _3) { + return __async(this, arguments, function* (flagKey, resolver, defaultValue, flagType, invocationContext = {}, options = {}) { + var _a; + const allHooks = [ + ...this.apiHooksAccessor(), + ...this.getHooks(), + ...options.hooks || [], + ...this._provider.hooks || [] + ]; + const allHooksReversed = [...allHooks].reverse(); + const mergedContext = this.mergeContexts(invocationContext); + const hookContext = { + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context: mergedContext, + logger: this._logger + }; + let evaluationDetails; + try { + const frozenContext = yield this.beforeHooks(allHooks, hookContext, options); + this.shortCircuitIfNotReady(); + const resolution = yield resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); + const resolutionDetails = __spreadProps(__spreadValues({}, resolution), { + flagMetadata: Object.freeze((_a = resolution.flagMetadata) != null ? _a : {}), + flagKey + }); + if (resolutionDetails.errorCode) { + const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); + yield this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); + } else { + yield this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + evaluationDetails = resolutionDetails; + } + } catch (err) { + yield this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); + } + yield this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + return evaluationDetails; + }); + } + beforeHooks(hooks, hookContext, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + Object.freeze(hookContext); + Object.assign(hookContext.context, __spreadValues(__spreadValues({}, hookContext.context), yield (_a = hook == null ? void 0 : hook.before) == null ? void 0 : _a.call(hook, hookContext, Object.freeze(options.hookHints)))); + } + return Object.freeze(hookContext.context); + }); + } + afterHooks(hooks, hookContext, evaluationDetails, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + yield (_a = hook == null ? void 0 : hook.after) == null ? void 0 : _a.call(hook, hookContext, evaluationDetails, options.hookHints); + } + }); + } + errorHooks(hooks, hookContext, err, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + try { + yield (_a = hook == null ? void 0 : hook.error) == null ? void 0 : _a.call(hook, hookContext, err, options.hookHints); + } catch (err2) { + this._logger.error(`Unhandled error during 'error' hook: ${err2}`); + if (err2 instanceof Error) { + this._logger.error(err2.stack); + } + this._logger.error(err2 == null ? void 0 : err2.stack); + } + } + }); + } + finallyHooks(hooks, hookContext, evaluationDetails, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + try { + yield (_a = hook == null ? void 0 : hook.finally) == null ? void 0 : _a.call(hook, hookContext, evaluationDetails, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error(err == null ? void 0 : err.stack); + } + } + }); + } + get _provider() { + return this.providerAccessor(); + } + get _providerStatus() { + return this.providerStatusAccessor(); + } + get _logger() { + return this._clientLogger || this.globalLogger(); + } + mergeContexts(invocationContext) { + return __spreadValues(__spreadValues(__spreadValues(__spreadValues({}, this.apiContextAccessor()), this.transactionContextAccessor()), this._context), invocationContext); + } + shortCircuitIfNotReady() { + if (this.providerStatus === ServerProviderStatus.NOT_READY) { + throw new ProviderNotReadyError("provider has not yet initialized"); + } else if (this.providerStatus === ServerProviderStatus.FATAL) { + throw new ProviderFatalError("provider is in an irrecoverable error state"); + } + } + getErrorEvaluationDetails(flagKey, defaultValue, err, flagMetadata = {}) { + const errorMessage = err == null ? void 0 : err.message; + const errorCode = (err == null ? void 0 : err.code) || ErrorCode2.GENERAL; + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons2.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey + }; + } +}; + +// src/events/open-feature-event-emitter.ts +import { GenericEventEmitter } from "@openfeature/core"; +import { EventEmitter } from "node:events"; +var OpenFeatureEventEmitter = class extends GenericEventEmitter { + constructor() { + super(); + this.eventEmitter = new EventEmitter({ captureRejections: true }); + this.eventEmitter.on("error", (err) => { + var _a; + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + }); + } +}; + +// src/events/events.ts +import { ServerProviderEvents } from "@openfeature/core"; + +// src/transaction-context/no-op-transaction-context-propagator.ts +var NoopTransactionContextPropagator = class { + getTransactionContext() { + return {}; + } + setTransactionContext(_, callback, ...args) { + callback(...args); + } +}; +var NOOP_TRANSACTION_CONTEXT_PROPAGATOR = new NoopTransactionContextPropagator(); + +// src/transaction-context/async-local-storage-transaction-context-propagator.ts +import { AsyncLocalStorage } from "async_hooks"; +var AsyncLocalStorageTransactionContextPropagator = class { + constructor() { + this.asyncLocalStorage = new AsyncLocalStorage(); + } + getTransactionContext() { + var _a; + return (_a = this.asyncLocalStorage.getStore()) != null ? _a : {}; + } + setTransactionContext(transactionContext, callback, ...args) { + this.asyncLocalStorage.run(transactionContext, callback, ...args); + } +}; + +// src/open-feature.ts +var GLOBAL_OPENFEATURE_API_KEY = Symbol.for("@openfeature/js-sdk/api"); +var _globalThis = globalThis; +var OpenFeatureAPI = class _OpenFeatureAPI extends OpenFeatureCommonAPI { + constructor() { + super("server"); + this._statusEnumType = ServerProviderStatus; + this._apiEmitter = new OpenFeatureEventEmitter(); + this._defaultProvider = new ProviderWrapper( + NOOP_PROVIDER, + ServerProviderStatus.NOT_READY, + this._statusEnumType + ); + this._domainScopedProviders = /* @__PURE__ */ new Map(); + this._createEventEmitter = () => new OpenFeatureEventEmitter(); + this._transactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + } + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance() { + const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + if (globalApi) { + return globalApi; + } + const instance = new _OpenFeatureAPI(); + _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + return instance; + } + getProviderStatus(domain) { + var _a, _b; + if (!domain) { + return this._defaultProvider.status; + } + return (_b = (_a = this._domainScopedProviders.get(domain)) == null ? void 0 : _a.status) != null ? _b : this._defaultProvider.status; + } + setProviderAndWait(domainOrProvider, providerOrUndefined) { + return __async(this, null, function* () { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain ? objectOrUndefined(providerOrUndefined) : objectOrUndefined(domainOrProvider); + yield this.setAwaitableProvider(domain, provider); + }); + } + setProvider(clientOrProvider, providerOrUndefined) { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain ? objectOrUndefined(providerOrUndefined) : objectOrUndefined(clientOrProvider); + const maybePromise = this.setAwaitableProvider(domain, provider); + Promise.resolve(maybePromise).catch((err) => { + this._logger.error("Error during provider initialization:", err); + }); + return this; + } + getProvider(domain) { + return this.getProviderForClient(domain); + } + setContext(context) { + this._context = context; + return this; + } + getContext() { + return this._context; + } + getClient(domainOrContext, versionOrContext, contextOrUndefined) { + var _a, _b; + const domain = stringOrUndefined(domainOrContext); + const version = stringOrUndefined(versionOrContext); + const context = (_b = (_a = objectOrUndefined(domainOrContext)) != null ? _a : objectOrUndefined(versionOrContext)) != null ? _b : objectOrUndefined(contextOrUndefined); + return new OpenFeatureClient( + () => this.getProviderForClient(domain), + () => this.getProviderStatus(domain), + () => this.buildAndCacheEventEmitterForClient(domain), + () => this.getContext(), + () => this.getHooks(), + () => this.getTransactionContext(), + () => this._logger, + { domain, version }, + context + ); + } + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders() { + return super.clearProvidersAndSetDefault(NOOP_PROVIDER); + } + setTransactionContextPropagator(transactionContextPropagator) { + const baseMessage = "Invalid TransactionContextPropagator, will not be set: "; + if (typeof (transactionContextPropagator == null ? void 0 : transactionContextPropagator.getTransactionContext) !== "function") { + this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); + } else if (typeof (transactionContextPropagator == null ? void 0 : transactionContextPropagator.setTransactionContext) !== "function") { + this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); + } else { + this._transactionContextPropagator = transactionContextPropagator; + } + return this; + } + setTransactionContext(transactionContext, callback, ...args) { + this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); + } + getTransactionContext() { + try { + return this._transactionContextPropagator.getTransactionContext(); + } catch (err) { + const error = err; + this._logger.error(`Error getting transaction context: ${error == null ? void 0 : error.message}, returning empty context.`); + this._logger.error(error == null ? void 0 : error.stack); + return {}; + } + } +}; +var OpenFeature = OpenFeatureAPI.getInstance(); + +// src/index.ts +export * from "@openfeature/core"; +export { + AsyncLocalStorageTransactionContextPropagator, + InMemoryProvider, + NOOP_PROVIDER, + NOOP_TRANSACTION_CONTEXT_PROPAGATOR, + OpenFeature, + OpenFeatureAPI, + OpenFeatureEventEmitter, + ServerProviderEvents as ProviderEvents, + ServerProviderStatus as ProviderStatus +}; +//# sourceMappingURL=index.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts new file mode 100644 index 0000000000..d1188cec44 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts @@ -0,0 +1,420 @@ +import { BaseHook, FlagValue, EvaluationContext, HookHints, EvaluationDetails, JsonValue, CommonProvider, ServerProviderStatus, Logger, ResolutionDetails, GenericEventEmitter, ServerProviderEvents, TrackingEventDetails, EvaluationLifeCycle, ManageContext, ManageLogger, Eventing, ClientMetadata, OpenFeatureCommonAPI, ProviderWrapper } from '@openfeature/core'; +export * from '@openfeature/core'; +export { ServerProviderEvents as ProviderEvents, ServerProviderStatus as ProviderStatus } from '@openfeature/core'; +import { EventEmitter } from 'node:events'; + +type Hook = BaseHook | EvaluationContext | void, Promise | void>; + +interface FlagEvaluationOptions { + hooks?: Hook[]; + hookHints?: HookHints; +} +interface Features { + /** + * Performs a flag evaluation that returns a boolean. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getBooleanValue(flagKey: string, defaultValue: boolean, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getBooleanDetails(flagKey: string, defaultValue: boolean, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns a string. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getStringValue(flagKey: string, defaultValue: string, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getStringValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getStringDetails(flagKey: string, defaultValue: string, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getStringDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns a number. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getNumberValue(flagKey: string, defaultValue: number, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getNumberValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getNumberDetails(flagKey: string, defaultValue: number, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getNumberDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns an object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getObjectValue(flagKey: string, defaultValue: JsonValue, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getObjectValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getObjectDetails(flagKey: string, defaultValue: JsonValue, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getObjectDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; +} + +/** + * Interface that providers must implement to resolve flag values for their particular + * backend or vendor. + * + * Implementation for resolving all the required flag types must be defined. + */ +interface Provider extends CommonProvider { + /** + * A provider hook exposes a mechanism for provider authors to register hooks + * to tap into various stages of the flag evaluation lifecycle. These hooks can + * be used to perform side effects and mutate the context for purposes of the + * provider. Provider hooks are not configured or controlled by the application author. + */ + readonly hooks?: Hook[]; + /** + * Resolve a boolean flag and its evaluation details. + */ + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve a string flag and its evaluation details. + */ + resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve a numeric flag and its evaluation details. + */ + resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve and parse an object flag and its evaluation details. + */ + resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger): Promise>; +} + +/** + * The No-op provider is set by default, and simply always returns the default value. + */ +declare class NoopFeatureProvider implements Provider { + readonly metadata: { + readonly name: "No-op Provider"; + }; + resolveBooleanEvaluation(_: string, defaultValue: boolean): Promise>; + resolveStringEvaluation(_: string, defaultValue: string): Promise>; + resolveNumberEvaluation(_: string, defaultValue: number): Promise>; + resolveObjectEvaluation(_: string, defaultValue: T): Promise>; + private noOp; +} +declare const NOOP_PROVIDER: NoopFeatureProvider; + +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ + +type Variants = Record; +/** + * A Feature Flag definition, containing it's specification + */ +type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; +type FlagConfiguration = Record; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +declare class InMemoryProvider implements Provider { + readonly events: OpenFeatureEventEmitter; + readonly runsOn = "server"; + readonly metadata: { + readonly name: "in-memory"; + }; + private _flagConfiguration; + constructor(flagConfiguration?: FlagConfiguration); + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration: FlagConfiguration): void; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context?: EvaluationContext, logger?: Logger): Promise>; + resolveNumberEvaluation(flagKey: string, defaultValue: number, context?: EvaluationContext, logger?: Logger): Promise>; + resolveStringEvaluation(flagKey: string, defaultValue: string, context?: EvaluationContext, logger?: Logger): Promise>; + resolveObjectEvaluation(flagKey: string, defaultValue: T, context?: EvaluationContext, logger?: Logger): Promise>; + private resolveFlagWithReason; + private lookupFlagValue; +} + +/** + * The OpenFeatureEventEmitter can be used by provider developers to emit + * events at various parts of the provider lifecycle. + * + * NOTE: Ready and error events are automatically emitted by the SDK based on + * the result of the initialize method. + */ +declare class OpenFeatureEventEmitter extends GenericEventEmitter { + protected readonly eventEmitter: EventEmitter<[never]>; + constructor(); +} + +interface Tracking { + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName an identifier for the event + * @param context the evaluation context + * @param trackingEventDetails the details of the tracking event + */ + track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void; +} + +interface Client extends EvaluationLifeCycle, Features, ManageContext, ManageLogger, Tracking, Eventing { + readonly metadata: ClientMetadata; + /** + * Returns the status of the associated provider. + */ + readonly providerStatus: ServerProviderStatus; +} + +/** + * Transaction context is a mechanism for adding transaction specific context that + * is merged with evaluation context prior to flag evaluation. Examples of potential + * transaction specific context include: a user id, user agent, or request path. + */ +type TransactionContext = EvaluationContext; +interface ManageTransactionContextPropagator extends TransactionContextPropagator { + /** + * Sets a transaction context propagator on this receiver. The transaction context + * propagator is responsible for persisting context for the duration of a single + * transaction. + * @template T The type of the receiver + * @param {TransactionContextPropagator} transactionContextPropagator The context propagator to be used + * @returns {T} The receiver (this object) + */ + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): T; +} +interface TransactionContextPropagator { + /** + * Returns the currently defined transaction context using the registered transaction + * context propagator. + * @returns {TransactionContext} The current transaction context + */ + getTransactionContext(): TransactionContext; + /** + * Sets the transaction context using the registered transaction context propagator. + * Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling + * {@link this#getTransactionContext}. + * + * The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available + * to {@link callback} via {@link this#getTransactionContext}. + * + * The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}. + * + * Example: + * + * ```js + * app.use((req: Request, res: Response, next: NextFunction) => { + * const ip = res.headers.get("X-Forwarded-For") + * OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => { + * // The transaction context is used in any flag evaluation throughout the whole call chain of next + * next(); + * }); + * }) + * + * ``` + * @template TArgs The optional args passed to the callback function + * @template R The return value of the callback + * @param {TransactionContext} transactionContext The transaction specific context + * @param {(...args: unknown[]) => R} callback Callback function to run + * @param {...unknown[]} args Optional arguments that are passed to the callback function + */ + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} + +declare class NoopTransactionContextPropagator implements TransactionContextPropagator { + getTransactionContext(): EvaluationContext; + setTransactionContext(_: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} +declare const NOOP_TRANSACTION_CONTEXT_PROPAGATOR: NoopTransactionContextPropagator; + +declare class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator { + private asyncLocalStorage; + getTransactionContext(): EvaluationContext; + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} + +declare class OpenFeatureAPI extends OpenFeatureCommonAPI implements ManageContext, ManageTransactionContextPropagator> { + protected _statusEnumType: typeof ServerProviderStatus; + protected _apiEmitter: OpenFeatureEventEmitter; + protected _defaultProvider: ProviderWrapper; + protected _domainScopedProviders: Map>; + protected _createEventEmitter: () => OpenFeatureEventEmitter; + private _transactionContextPropagator; + private constructor(); + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance(): OpenFeatureAPI; + private getProviderStatus; + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + /** + * Sets the default provider for flag evaluations. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(provider: Provider): this; + /** + * Sets the provider for flag evaluations of providers with the given name. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(domain: string, provider: Provider): this; + /** + * Get the default provider. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @returns {Provider} Default Provider + */ + getProvider(): Provider; + /** + * Get the provider bound to the specified domain. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {Provider} Domain-scoped provider + */ + getProvider(domain?: string): Provider; + setContext(context: EvaluationContext): this; + getContext(): EvaluationContext; + /** + * A factory function for creating new domainless OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {string} version The version of the client (only used for metadata) + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, version: string, context?: EvaluationContext): Client; + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders(): Promise; + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): OpenFeatureCommonAPI; + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; + getTransactionContext(): TransactionContext; +} +/** + * A singleton instance of the OpenFeature API. + * @returns {OpenFeatureAPI} OpenFeature API + */ +declare const OpenFeature: OpenFeatureAPI; + +export { AsyncLocalStorageTransactionContextPropagator, type Client, type Features, type FlagEvaluationOptions, type Hook, InMemoryProvider, type ManageTransactionContextPropagator, NOOP_PROVIDER, NOOP_TRANSACTION_CONTEXT_PROPAGATOR, OpenFeature, OpenFeatureAPI, OpenFeatureEventEmitter, type Provider, type Tracking, type TransactionContext, type TransactionContextPropagator }; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json new file mode 100644 index 0000000000..8e039cfdc9 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json @@ -0,0 +1,56 @@ +{ + "name": "@openfeature/server-sdk", + "version": "1.18.0", + "description": "OpenFeature SDK for JavaScript", + "main": "./dist/cjs/index.js", + "files": [ + "dist/" + ], + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "types": "./dist/types.d.ts", + "scripts": { + "test": "jest --verbose", + "lint": "eslint ./", + "lint:fix": "eslint ./ --fix", + "clean": "shx rm -rf ./dist", + "build:esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze", + "build:cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:rollup-types": "rollup -c ../../rollup.config.mjs", + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types", + "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", + "current-version": "echo $npm_package_version", + "prepack": "shx cp ./../../LICENSE ./LICENSE", + "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-feature/js-sdk.git" + }, + "keywords": [ + "openfeature", + "feature", + "flags", + "toggles", + "node" + ], + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-feature/js-sdk/issues" + }, + "homepage": "https://github.com/open-feature/js-sdk#readme", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@openfeature/core": "^1.7.0" + }, + "devDependencies": { + "@openfeature/core": "^1.7.0" + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE new file mode 100644 index 0000000000..84c52fe2cf --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js new file mode 100644 index 0000000000..b30a31a9a8 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js @@ -0,0 +1,2 @@ +module.exports=function(n){return{all:n=n||new Map,on:function(e,t){var i=n.get(e);i?i.push(t):n.set(e,[t])},off:function(e,t){var i=n.get(e);i&&(t?i.splice(i.indexOf(t)>>>0,1):n.set(e,[]))},emit:function(e,t){var i=n.get(e);i&&i.slice().map(function(n){n(t)}),(i=n.get("*"))&&i.slice().map(function(n){n(e,t)})}}}; +//# sourceMappingURL=mitt.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs new file mode 100644 index 0000000000..5a2e73a666 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs @@ -0,0 +1,2 @@ +export default function(n){return{all:n=n||new Map,on:function(t,e){var i=n.get(t);i?i.push(e):n.set(t,[e])},off:function(t,e){var i=n.get(t);i&&(e?i.splice(i.indexOf(e)>>>0,1):n.set(t,[]))},emit:function(t,e){var i=n.get(t);i&&i.slice().map(function(n){n(e)}),(i=n.get("*"))&&i.slice().map(function(n){n(t,e)})}}} +//# sourceMappingURL=mitt.mjs.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js new file mode 100644 index 0000000000..6820e65874 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js @@ -0,0 +1,2 @@ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).mitt=n()}(this,function(){return function(e){return{all:e=e||new Map,on:function(n,t){var f=e.get(n);f?f.push(t):e.set(n,[t])},off:function(n,t){var f=e.get(n);f&&(t?f.splice(f.indexOf(t)>>>0,1):e.set(n,[]))},emit:function(n,t){var f=e.get(n);f&&f.slice().map(function(e){e(t)}),(f=e.get("*"))&&f.slice().map(function(e){e(n,t)})}}}}); +//# sourceMappingURL=mitt.umd.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts new file mode 100644 index 0000000000..10dc70d2c0 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts @@ -0,0 +1,21 @@ +export declare type EventType = string | symbol; +export declare type Handler = (event: T) => void; +export declare type WildcardHandler> = (type: keyof T, event: T[keyof T]) => void; +export declare type EventHandlerList = Array>; +export declare type WildCardEventHandlerList> = Array>; +export declare type EventHandlerMap> = Map | WildCardEventHandlerList>; +export interface Emitter> { + all: EventHandlerMap; + on(type: Key, handler: Handler): void; + on(type: '*', handler: WildcardHandler): void; + off(type: Key, handler?: Handler): void; + off(type: '*', handler: WildcardHandler): void; + emit(type: Key, event: Events[Key]): void; + emit(type: undefined extends Events[Key] ? Key : never): void; +} +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} + */ +export default function mitt>(all?: EventHandlerMap): Emitter; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json new file mode 100644 index 0000000000..e51397cb41 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json @@ -0,0 +1,85 @@ +{ + "name": "mitt", + "version": "3.0.1", + "description": "Tiny 200b functional Event Emitter / pubsub.", + "module": "dist/mitt.mjs", + "main": "dist/mitt.js", + "jsnext:main": "dist/mitt.mjs", + "umd:main": "dist/mitt.umd.js", + "source": "src/index.ts", + "typings": "index.d.ts", + "exports": { + "types": "./index.d.ts", + "module": "./dist/mitt.mjs", + "import": "./dist/mitt.mjs", + "require": "./dist/mitt.js", + "default": "./dist/mitt.mjs" + }, + "scripts": { + "test": "npm-run-all --silent typecheck lint mocha test-types", + "mocha": "mocha test", + "test-types": "tsc test/test-types-compilation.ts --noEmit --strict", + "lint": "eslint src test --ext ts --ext js", + "typecheck": "tsc --noEmit", + "bundle": "microbundle -f es,cjs,umd", + "build": "npm-run-all --silent clean -p bundle -s docs", + "clean": "rimraf dist", + "docs": "documentation readme src/index.ts --section API -q --parse-extension ts", + "release": "npm run -s build -s && npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" + }, + "repository": "developit/mitt", + "keywords": [ + "events", + "eventemitter", + "emitter", + "pubsub" + ], + "homepage": "https://github.com/developit/mitt", + "authors": [ + "Jason Miller " + ], + "license": "MIT", + "files": [ + "dist", + "index.d.ts" + ], + "mocha": { + "extension": [ + "ts" + ], + "require": [ + "ts-node/register", + "esm" + ], + "spec": [ + "test/*_test.ts" + ] + }, + "prettier": { + "singleQuote": true, + "trailingComma": "none" + }, + "devDependencies": { + "@types/chai": "^4.2.11", + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.4", + "@types/sinon-chai": "^3.2.4", + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "chai": "^4.2.0", + "documentation": "^14.0.2", + "eslint": "^7.32.0", + "eslint-config-developit": "^1.2.0", + "eslint-plugin-compat": "^4.1.4", + "esm": "^3.2.25", + "microbundle": "^0.12.3", + "mocha": "^8.0.1", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.8", + "rimraf": "^3.0.2", + "sinon": "^9.0.2", + "sinon-chai": "^3.5.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE new file mode 100644 index 0000000000..983fbe8aec --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js new file mode 100644 index 0000000000..62e30de932 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js @@ -0,0 +1,415 @@ +/** + * Tokenize input string. + */ +function lexer(str) { + var tokens = []; + var i = 0; + while (i < str.length) { + var char = str[i]; + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + if (char === ":") { + var name = ""; + var j = i + 1; + while (j < str.length) { + var code = str.charCodeAt(j); + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95) { + name += str[j++]; + continue; + } + break; + } + if (!name) + throw new TypeError("Missing parameter name at ".concat(i)); + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + if (char === "(") { + var count = 1; + var pattern = ""; + var j = i + 1; + if (str[j] === "?") { + throw new TypeError("Pattern cannot start with \"?\" at ".concat(j)); + } + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } + else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError("Capturing groups are not allowed at ".concat(j)); + } + } + pattern += str[j++]; + } + if (count) + throw new TypeError("Unbalanced pattern at ".concat(i)); + if (!pattern) + throw new TypeError("Missing pattern at ".concat(i)); + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + tokens.push({ type: "END", index: i, value: "" }); + return tokens; +} +/** + * Parse a string for the raw tokens. + */ +export function parse(str, options) { + if (options === void 0) { options = {}; } + var tokens = lexer(str); + var _a = options.prefixes, prefixes = _a === void 0 ? "./" : _a, _b = options.delimiter, delimiter = _b === void 0 ? "/#?" : _b; + var result = []; + var key = 0; + var i = 0; + var path = ""; + var tryConsume = function (type) { + if (i < tokens.length && tokens[i].type === type) + return tokens[i++].value; + }; + var mustConsume = function (type) { + var value = tryConsume(type); + if (value !== undefined) + return value; + var _a = tokens[i], nextType = _a.type, index = _a.index; + throw new TypeError("Unexpected ".concat(nextType, " at ").concat(index, ", expected ").concat(type)); + }; + var consumeText = function () { + var result = ""; + var value; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + var isSafe = function (value) { + for (var _i = 0, delimiter_1 = delimiter; _i < delimiter_1.length; _i++) { + var char = delimiter_1[_i]; + if (value.indexOf(char) > -1) + return true; + } + return false; + }; + var safePattern = function (prefix) { + var prev = result[result.length - 1]; + var prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + if (prev && !prevText) { + throw new TypeError("Must have text between two parameters, missing text after \"".concat(prev.name, "\"")); + } + if (!prevText || isSafe(prevText)) + return "[^".concat(escapeString(delimiter), "]+?"); + return "(?:(?!".concat(escapeString(prevText), ")[^").concat(escapeString(delimiter), "])+?"); + }; + while (i < tokens.length) { + var char = tryConsume("CHAR"); + var name = tryConsume("NAME"); + var pattern = tryConsume("PATTERN"); + if (name || pattern) { + var prefix = char || ""; + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + if (path) { + result.push(path); + path = ""; + } + result.push({ + name: name || key++, + prefix: prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + var value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + if (path) { + result.push(path); + path = ""; + } + var open = tryConsume("OPEN"); + if (open) { + var prefix = consumeText(); + var name_1 = tryConsume("NAME") || ""; + var pattern_1 = tryConsume("PATTERN") || ""; + var suffix = consumeText(); + mustConsume("CLOSE"); + result.push({ + name: name_1 || (pattern_1 ? key++ : ""), + pattern: name_1 && !pattern_1 ? safePattern(prefix) : pattern_1, + prefix: prefix, + suffix: suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + mustConsume("END"); + } + return result; +} +/** + * Compile a string to a template function for the path. + */ +export function compile(str, options) { + return tokensToFunction(parse(str, options), options); +} +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction(tokens, options) { + if (options === void 0) { options = {}; } + var reFlags = flags(options); + var _a = options.encode, encode = _a === void 0 ? function (x) { return x; } : _a, _b = options.validate, validate = _b === void 0 ? true : _b; + // Compile all the tokens into regexps. + var matches = tokens.map(function (token) { + if (typeof token === "object") { + return new RegExp("^(?:".concat(token.pattern, ")$"), reFlags); + } + }); + return function (data) { + var path = ""; + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (typeof token === "string") { + path += token; + continue; + } + var value = data ? data[token.name] : undefined; + var optional = token.modifier === "?" || token.modifier === "*"; + var repeat = token.modifier === "*" || token.modifier === "+"; + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError("Expected \"".concat(token.name, "\" to not repeat, but got an array")); + } + if (value.length === 0) { + if (optional) + continue; + throw new TypeError("Expected \"".concat(token.name, "\" to not be empty")); + } + for (var j = 0; j < value.length; j++) { + var segment = encode(value[j], token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected all \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + var segment = encode(String(value), token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + continue; + } + if (optional) + continue; + var typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError("Expected \"".concat(token.name, "\" to be ").concat(typeOfMessage)); + } + return path; + }; +} +/** + * Create path match function from `path-to-regexp` spec. + */ +export function match(str, options) { + var keys = []; + var re = pathToRegexp(str, keys, options); + return regexpToFunction(re, keys, options); +} +/** + * Create a path match function from `path-to-regexp` output. + */ +export function regexpToFunction(re, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.decode, decode = _a === void 0 ? function (x) { return x; } : _a; + return function (pathname) { + var m = re.exec(pathname); + if (!m) + return false; + var path = m[0], index = m.index; + var params = Object.create(null); + var _loop_1 = function (i) { + if (m[i] === undefined) + return "continue"; + var key = keys[i - 1]; + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map(function (value) { + return decode(value, key); + }); + } + else { + params[key.name] = decode(m[i], key); + } + }; + for (var i = 1; i < m.length; i++) { + _loop_1(i); + } + return { path: path, index: index, params: params }; + }; +} +/** + * Escape a regular expression string. + */ +function escapeString(str) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} +/** + * Get the flags for a regexp from the options. + */ +function flags(options) { + return options && options.sensitive ? "" : "i"; +} +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path, keys) { + if (!keys) + return path; + var groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + var index = 0; + var execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + return path; +} +/** + * Transform an array into a regexp. + */ +function arrayToRegexp(paths, keys, options) { + var parts = paths.map(function (path) { return pathToRegexp(path, keys, options).source; }); + return new RegExp("(?:".concat(parts.join("|"), ")"), flags(options)); +} +/** + * Create a path regexp from string input. + */ +function stringToRegexp(path, keys, options) { + return tokensToRegexp(parse(path, options), keys, options); +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export function tokensToRegexp(tokens, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.strict, strict = _a === void 0 ? false : _a, _b = options.start, start = _b === void 0 ? true : _b, _c = options.end, end = _c === void 0 ? true : _c, _d = options.encode, encode = _d === void 0 ? function (x) { return x; } : _d, _e = options.delimiter, delimiter = _e === void 0 ? "/#?" : _e, _f = options.endsWith, endsWith = _f === void 0 ? "" : _f; + var endsWithRe = "[".concat(escapeString(endsWith), "]|$"); + var delimiterRe = "[".concat(escapeString(delimiter), "]"); + var route = start ? "^" : ""; + // Iterate over the tokens and create our regexp string. + for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) { + var token = tokens_1[_i]; + if (typeof token === "string") { + route += escapeString(encode(token)); + } + else { + var prefix = escapeString(encode(token.prefix)); + var suffix = escapeString(encode(token.suffix)); + if (token.pattern) { + if (keys) + keys.push(token); + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + var mod = token.modifier === "*" ? "?" : ""; + route += "(?:".concat(prefix, "((?:").concat(token.pattern, ")(?:").concat(suffix).concat(prefix, "(?:").concat(token.pattern, "))*)").concat(suffix, ")").concat(mod); + } + else { + route += "(?:".concat(prefix, "(").concat(token.pattern, ")").concat(suffix, ")").concat(token.modifier); + } + } + else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError("Can not repeat \"".concat(token.name, "\" without a prefix and suffix")); + } + route += "(".concat(token.pattern, ")").concat(token.modifier); + } + } + else { + route += "(?:".concat(prefix).concat(suffix, ")").concat(token.modifier); + } + } + } + if (end) { + if (!strict) + route += "".concat(delimiterRe, "?"); + route += !options.endsWith ? "$" : "(?=".concat(endsWithRe, ")"); + } + else { + var endToken = tokens[tokens.length - 1]; + var isEndDelimited = typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + if (!strict) { + route += "(?:".concat(delimiterRe, "(?=").concat(endsWithRe, "))?"); + } + if (!isEndDelimited) { + route += "(?=".concat(delimiterRe, "|").concat(endsWithRe, ")"); + } + } + return new RegExp(route, flags(options)); +} +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export function pathToRegexp(path, keys, options) { + if (path instanceof RegExp) + return regexpToRegexp(path, keys); + if (Array.isArray(path)) + return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts new file mode 100644 index 0000000000..6e5d250e3c --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts @@ -0,0 +1,127 @@ +export interface ParseOptions { + /** + * Set the default delimiter for repeat parameters. (default: `'/'`) + */ + delimiter?: string; + /** + * List of characters to automatically consider prefixes when parsing. + */ + prefixes?: string; +} +/** + * Parse a string for the raw tokens. + */ +export declare function parse(str: string, options?: ParseOptions): Token[]; +export interface TokensToFunctionOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * Function for encoding input strings for output. + */ + encode?: (value: string, token: Key) => string; + /** + * When `false` the function can produce an invalid (unmatched) path. (default: `true`) + */ + validate?: boolean; +} +/** + * Compile a string to a template function for the path. + */ +export declare function compile

(str: string, options?: ParseOptions & TokensToFunctionOptions): PathFunction

; +export type PathFunction

= (data?: P) => string; +/** + * Expose a method for transforming tokens into the path function. + */ +export declare function tokensToFunction

(tokens: Token[], options?: TokensToFunctionOptions): PathFunction

; +export interface RegexpToFunctionOptions { + /** + * Function for decoding strings for params. + */ + decode?: (value: string, token: Key) => string; +} +/** + * A match result contains data about the path match. + */ +export interface MatchResult

{ + path: string; + index: number; + params: P; +} +/** + * A match is either `false` (no match) or a match result. + */ +export type Match

= false | MatchResult

; +/** + * The match function takes a string and returns whether it matched the path. + */ +export type MatchFunction

= (path: string) => Match

; +/** + * Create path match function from `path-to-regexp` spec. + */ +export declare function match

(str: Path, options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions): MatchFunction

; +/** + * Create a path match function from `path-to-regexp` output. + */ +export declare function regexpToFunction

(re: RegExp, keys: Key[], options?: RegexpToFunctionOptions): MatchFunction

; +/** + * Metadata about a key. + */ +export interface Key { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +} +/** + * A token is a string (nothing special) or key metadata (capture group). + */ +export type Token = string | Key; +export interface TokensToRegexpOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) + */ + strict?: boolean; + /** + * When `true` the regexp will match to the end of the string. (default: `true`) + */ + end?: boolean; + /** + * When `true` the regexp will match from the beginning of the string. (default: `true`) + */ + start?: boolean; + /** + * Sets the final character for non-ending optimistic matches. (default: `/`) + */ + delimiter?: string; + /** + * List of characters that can also be "end" characters. + */ + endsWith?: string; + /** + * Encode path tokens for use in the `RegExp`. + */ + encode?: (value: string) => string; +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export declare function tokensToRegexp(tokens: Token[], keys?: Key[], options?: TokensToRegexpOptions): RegExp; +/** + * Supported `path-to-regexp` input types. + */ +export type Path = string | RegExp | Array; +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export declare function pathToRegexp(path: Path, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions): RegExp; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js new file mode 100644 index 0000000000..7ee8a6aae6 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js @@ -0,0 +1,425 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pathToRegexp = exports.tokensToRegexp = exports.regexpToFunction = exports.match = exports.tokensToFunction = exports.compile = exports.parse = void 0; +/** + * Tokenize input string. + */ +function lexer(str) { + var tokens = []; + var i = 0; + while (i < str.length) { + var char = str[i]; + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + if (char === ":") { + var name = ""; + var j = i + 1; + while (j < str.length) { + var code = str.charCodeAt(j); + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95) { + name += str[j++]; + continue; + } + break; + } + if (!name) + throw new TypeError("Missing parameter name at ".concat(i)); + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + if (char === "(") { + var count = 1; + var pattern = ""; + var j = i + 1; + if (str[j] === "?") { + throw new TypeError("Pattern cannot start with \"?\" at ".concat(j)); + } + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } + else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError("Capturing groups are not allowed at ".concat(j)); + } + } + pattern += str[j++]; + } + if (count) + throw new TypeError("Unbalanced pattern at ".concat(i)); + if (!pattern) + throw new TypeError("Missing pattern at ".concat(i)); + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + tokens.push({ type: "END", index: i, value: "" }); + return tokens; +} +/** + * Parse a string for the raw tokens. + */ +function parse(str, options) { + if (options === void 0) { options = {}; } + var tokens = lexer(str); + var _a = options.prefixes, prefixes = _a === void 0 ? "./" : _a, _b = options.delimiter, delimiter = _b === void 0 ? "/#?" : _b; + var result = []; + var key = 0; + var i = 0; + var path = ""; + var tryConsume = function (type) { + if (i < tokens.length && tokens[i].type === type) + return tokens[i++].value; + }; + var mustConsume = function (type) { + var value = tryConsume(type); + if (value !== undefined) + return value; + var _a = tokens[i], nextType = _a.type, index = _a.index; + throw new TypeError("Unexpected ".concat(nextType, " at ").concat(index, ", expected ").concat(type)); + }; + var consumeText = function () { + var result = ""; + var value; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + var isSafe = function (value) { + for (var _i = 0, delimiter_1 = delimiter; _i < delimiter_1.length; _i++) { + var char = delimiter_1[_i]; + if (value.indexOf(char) > -1) + return true; + } + return false; + }; + var safePattern = function (prefix) { + var prev = result[result.length - 1]; + var prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + if (prev && !prevText) { + throw new TypeError("Must have text between two parameters, missing text after \"".concat(prev.name, "\"")); + } + if (!prevText || isSafe(prevText)) + return "[^".concat(escapeString(delimiter), "]+?"); + return "(?:(?!".concat(escapeString(prevText), ")[^").concat(escapeString(delimiter), "])+?"); + }; + while (i < tokens.length) { + var char = tryConsume("CHAR"); + var name = tryConsume("NAME"); + var pattern = tryConsume("PATTERN"); + if (name || pattern) { + var prefix = char || ""; + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + if (path) { + result.push(path); + path = ""; + } + result.push({ + name: name || key++, + prefix: prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + var value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + if (path) { + result.push(path); + path = ""; + } + var open = tryConsume("OPEN"); + if (open) { + var prefix = consumeText(); + var name_1 = tryConsume("NAME") || ""; + var pattern_1 = tryConsume("PATTERN") || ""; + var suffix = consumeText(); + mustConsume("CLOSE"); + result.push({ + name: name_1 || (pattern_1 ? key++ : ""), + pattern: name_1 && !pattern_1 ? safePattern(prefix) : pattern_1, + prefix: prefix, + suffix: suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + mustConsume("END"); + } + return result; +} +exports.parse = parse; +/** + * Compile a string to a template function for the path. + */ +function compile(str, options) { + return tokensToFunction(parse(str, options), options); +} +exports.compile = compile; +/** + * Expose a method for transforming tokens into the path function. + */ +function tokensToFunction(tokens, options) { + if (options === void 0) { options = {}; } + var reFlags = flags(options); + var _a = options.encode, encode = _a === void 0 ? function (x) { return x; } : _a, _b = options.validate, validate = _b === void 0 ? true : _b; + // Compile all the tokens into regexps. + var matches = tokens.map(function (token) { + if (typeof token === "object") { + return new RegExp("^(?:".concat(token.pattern, ")$"), reFlags); + } + }); + return function (data) { + var path = ""; + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (typeof token === "string") { + path += token; + continue; + } + var value = data ? data[token.name] : undefined; + var optional = token.modifier === "?" || token.modifier === "*"; + var repeat = token.modifier === "*" || token.modifier === "+"; + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError("Expected \"".concat(token.name, "\" to not repeat, but got an array")); + } + if (value.length === 0) { + if (optional) + continue; + throw new TypeError("Expected \"".concat(token.name, "\" to not be empty")); + } + for (var j = 0; j < value.length; j++) { + var segment = encode(value[j], token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected all \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + var segment = encode(String(value), token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + continue; + } + if (optional) + continue; + var typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError("Expected \"".concat(token.name, "\" to be ").concat(typeOfMessage)); + } + return path; + }; +} +exports.tokensToFunction = tokensToFunction; +/** + * Create path match function from `path-to-regexp` spec. + */ +function match(str, options) { + var keys = []; + var re = pathToRegexp(str, keys, options); + return regexpToFunction(re, keys, options); +} +exports.match = match; +/** + * Create a path match function from `path-to-regexp` output. + */ +function regexpToFunction(re, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.decode, decode = _a === void 0 ? function (x) { return x; } : _a; + return function (pathname) { + var m = re.exec(pathname); + if (!m) + return false; + var path = m[0], index = m.index; + var params = Object.create(null); + var _loop_1 = function (i) { + if (m[i] === undefined) + return "continue"; + var key = keys[i - 1]; + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map(function (value) { + return decode(value, key); + }); + } + else { + params[key.name] = decode(m[i], key); + } + }; + for (var i = 1; i < m.length; i++) { + _loop_1(i); + } + return { path: path, index: index, params: params }; + }; +} +exports.regexpToFunction = regexpToFunction; +/** + * Escape a regular expression string. + */ +function escapeString(str) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} +/** + * Get the flags for a regexp from the options. + */ +function flags(options) { + return options && options.sensitive ? "" : "i"; +} +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path, keys) { + if (!keys) + return path; + var groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + var index = 0; + var execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + return path; +} +/** + * Transform an array into a regexp. + */ +function arrayToRegexp(paths, keys, options) { + var parts = paths.map(function (path) { return pathToRegexp(path, keys, options).source; }); + return new RegExp("(?:".concat(parts.join("|"), ")"), flags(options)); +} +/** + * Create a path regexp from string input. + */ +function stringToRegexp(path, keys, options) { + return tokensToRegexp(parse(path, options), keys, options); +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +function tokensToRegexp(tokens, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.strict, strict = _a === void 0 ? false : _a, _b = options.start, start = _b === void 0 ? true : _b, _c = options.end, end = _c === void 0 ? true : _c, _d = options.encode, encode = _d === void 0 ? function (x) { return x; } : _d, _e = options.delimiter, delimiter = _e === void 0 ? "/#?" : _e, _f = options.endsWith, endsWith = _f === void 0 ? "" : _f; + var endsWithRe = "[".concat(escapeString(endsWith), "]|$"); + var delimiterRe = "[".concat(escapeString(delimiter), "]"); + var route = start ? "^" : ""; + // Iterate over the tokens and create our regexp string. + for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) { + var token = tokens_1[_i]; + if (typeof token === "string") { + route += escapeString(encode(token)); + } + else { + var prefix = escapeString(encode(token.prefix)); + var suffix = escapeString(encode(token.suffix)); + if (token.pattern) { + if (keys) + keys.push(token); + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + var mod = token.modifier === "*" ? "?" : ""; + route += "(?:".concat(prefix, "((?:").concat(token.pattern, ")(?:").concat(suffix).concat(prefix, "(?:").concat(token.pattern, "))*)").concat(suffix, ")").concat(mod); + } + else { + route += "(?:".concat(prefix, "(").concat(token.pattern, ")").concat(suffix, ")").concat(token.modifier); + } + } + else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError("Can not repeat \"".concat(token.name, "\" without a prefix and suffix")); + } + route += "(".concat(token.pattern, ")").concat(token.modifier); + } + } + else { + route += "(?:".concat(prefix).concat(suffix, ")").concat(token.modifier); + } + } + } + if (end) { + if (!strict) + route += "".concat(delimiterRe, "?"); + route += !options.endsWith ? "$" : "(?=".concat(endsWithRe, ")"); + } + else { + var endToken = tokens[tokens.length - 1]; + var isEndDelimited = typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + if (!strict) { + route += "(?:".concat(delimiterRe, "(?=").concat(endsWithRe, "))?"); + } + if (!isEndDelimited) { + route += "(?=".concat(delimiterRe, "|").concat(endsWithRe, ")"); + } + } + return new RegExp(route, flags(options)); +} +exports.tokensToRegexp = tokensToRegexp; +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +function pathToRegexp(path, keys, options) { + if (path instanceof RegExp) + return regexpToRegexp(path, keys); + if (Array.isArray(path)) + return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} +exports.pathToRegexp = pathToRegexp; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json new file mode 100644 index 0000000000..aa7a4f6681 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json @@ -0,0 +1,63 @@ +{ + "name": "path-to-regexp", + "version": "6.3.0", + "description": "Express style path to RegExp utility", + "keywords": [ + "express", + "regexp", + "route", + "routing" + ], + "repository": { + "type": "git", + "url": "https://github.com/pillarjs/path-to-regexp.git" + }, + "license": "MIT", + "sideEffects": false, + "main": "dist/index.js", + "module": "dist.es2015/index.js", + "typings": "dist/index.d.ts", + "files": [ + "dist.es2015/", + "dist/" + ], + "scripts": { + "build": "ts-scripts build", + "format": "ts-scripts format", + "lint": "ts-scripts lint", + "prepare": "ts-scripts install && npm run build", + "size": "size-limit", + "specs": "ts-scripts specs", + "test": "ts-scripts test && npm run size" + }, + "devDependencies": { + "@borderless/ts-scripts": "^0.15.0", + "@size-limit/preset-small-lib": "^11.1.2", + "@types/node": "^20.4.9", + "@types/semver": "^7.3.1", + "@vitest/coverage-v8": "^1.4.0", + "recheck": "^4.4.5", + "semver": "^7.3.5", + "size-limit": "^11.1.2", + "typescript": "^5.1.6" + }, + "publishConfig": { + "access": "public" + }, + "size-limit": [ + { + "path": "dist.es2015/index.js", + "limit": "2.1 kB" + } + ], + "ts-scripts": { + "dist": [ + "dist", + "dist.es2015" + ], + "project": [ + "tsconfig.build.json", + "tsconfig.es2015.json" + ] + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/@openfeature/core b/tests/fixtures/source-overlays/project/node_modules/@openfeature/core new file mode 120000 index 0000000000..5f4d646608 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/@openfeature/core @@ -0,0 +1 @@ +../.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk b/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk new file mode 120000 index 0000000000..66f36b5846 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk @@ -0,0 +1 @@ +../.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/mitt b/tests/fixtures/source-overlays/project/node_modules/mitt new file mode 120000 index 0000000000..5ebf4e45cf --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/mitt @@ -0,0 +1 @@ +.pnpm/mitt@3.0.1/node_modules/mitt \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/path-to-regexp b/tests/fixtures/source-overlays/project/node_modules/path-to-regexp new file mode 120000 index 0000000000..81a20318fa --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/path-to-regexp @@ -0,0 +1 @@ +.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/package.json b/tests/fixtures/source-overlays/project/package.json new file mode 100644 index 0000000000..e9058f7fa9 --- /dev/null +++ b/tests/fixtures/source-overlays/project/package.json @@ -0,0 +1 @@ +{"name":"spm-fixture","private":true,"type":"module","dependencies":{"@openfeature/core":"1.9.2","@openfeature/server-sdk":"1.18.0","mitt":"3.0.1","path-to-regexp":"6.3.0"}} diff --git a/tests/fixtures/source-overlays/project/require.cts b/tests/fixtures/source-overlays/project/require.cts new file mode 100644 index 0000000000..16f658c607 --- /dev/null +++ b/tests/fixtures/source-overlays/project/require.cts @@ -0,0 +1,3 @@ +const { pathToRegexp } = require("path-to-regexp") as typeof import("path-to-regexp"); + +console.log(pathToRegexp("/a/:b").source); diff --git a/tests/fixtures/source-overlays/project/routes.ts b/tests/fixtures/source-overlays/project/routes.ts new file mode 100644 index 0000000000..a826d0ef42 --- /dev/null +++ b/tests/fixtures/source-overlays/project/routes.ts @@ -0,0 +1,18 @@ +import { parse } from "path-to-regexp"; + +for (const route of ["/a/:b/(\\d+)/:c?", "/files/:dir+/:name.json", "/:lang(en|de)/docs/:rest*"]) { + const tokens = parse(route); + console.log(route, tokens.length); + for (const token of tokens) { + console.log( + typeof token === "string" + ? ` text ${token}` + : ` key ${token.name} prefix=${token.prefix} pattern=${token.pattern} modifier=${token.modifier}`, + ); + } +} +try { + parse("/:bad("); +} catch (e) { + console.log("error", (e as Error).message); +} diff --git a/tests/fixtures/source-overlays/sources/core/LICENSE b/tests/fixtures/source-overlays/sources/core/LICENSE new file mode 100644 index 0000000000..96b3dc8fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/sources/core/src/client/client.ts b/tests/fixtures/source-overlays/sources/core/src/client/client.ts new file mode 100644 index 0000000000..f487e9ddb9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/client/client.ts @@ -0,0 +1,11 @@ +import type { ProviderMetadata } from '../provider/provider'; + +export interface ClientMetadata { + /** + * @deprecated alias of "domain", use domain instead + */ + readonly name?: string; + readonly domain?: string; + readonly version?: string; + readonly providerMetadata: ProviderMetadata; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/client/index.ts b/tests/fixtures/source-overlays/sources/core/src/client/index.ts new file mode 100644 index 0000000000..4f1cce44fa --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/client/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts new file mode 100644 index 0000000000..b1bc128523 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class FlagNotFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, FlagNotFoundError.prototype); + this.name = 'FlagNotFoundError'; + this.code = ErrorCode.FLAG_NOT_FOUND; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts new file mode 100644 index 0000000000..a78caf8a42 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class GeneralError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, GeneralError.prototype); + this.name = 'GeneralError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/index.ts b/tests/fixtures/source-overlays/sources/core/src/errors/index.ts new file mode 100644 index 0000000000..bd87e965af --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/index.ts @@ -0,0 +1,45 @@ +import { ErrorCode } from '../evaluation'; + +import { FlagNotFoundError } from './flag-not-found-error'; +import { GeneralError } from './general-error'; +import { InvalidContextError } from './invalid-context-error'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ParseError } from './parse-error'; +import { ProviderFatalError } from './provider-fatal-error'; +import { ProviderNotReadyError } from './provider-not-ready-error'; +import { TargetingKeyMissingError } from './targeting-key-missing-error'; +import { TypeMismatchError } from './type-mismatch-error'; + +const instantiateErrorByErrorCode = (errorCode: ErrorCode, message?: string): OpenFeatureError => { + switch (errorCode) { + case ErrorCode.FLAG_NOT_FOUND: + return new FlagNotFoundError(message); + case ErrorCode.PARSE_ERROR: + return new ParseError(message); + case ErrorCode.TYPE_MISMATCH: + return new TypeMismatchError(message); + case ErrorCode.TARGETING_KEY_MISSING: + return new TargetingKeyMissingError(message); + case ErrorCode.INVALID_CONTEXT: + return new InvalidContextError(message); + case ErrorCode.PROVIDER_NOT_READY: + return new ProviderNotReadyError(message); + case ErrorCode.PROVIDER_FATAL: + return new ProviderFatalError(message); + default: + return new GeneralError(message); + } +}; + +export { + FlagNotFoundError, + GeneralError, + InvalidContextError, + ParseError, + ProviderFatalError, + ProviderNotReadyError, + TargetingKeyMissingError, + TypeMismatchError, + OpenFeatureError, + instantiateErrorByErrorCode, +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts new file mode 100644 index 0000000000..3213ee0a0d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class InvalidContextError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, InvalidContextError.prototype); + this.name = 'InvalidContextError'; + this.code = ErrorCode.INVALID_CONTEXT; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts b/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts new file mode 100644 index 0000000000..9e2f5e2233 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts @@ -0,0 +1,20 @@ +import type { ErrorCode } from '../evaluation'; + +/** + * Error Options were added in ES2022. Manually adding the type so that an + * earlier target can be used. + */ +export type ErrorOptions = { + cause?: unknown; +}; + +export abstract class OpenFeatureError extends Error { + abstract code: ErrorCode; + cause?: unknown; + constructor(message?: string, options?: ErrorOptions) { + super(message); + Object.setPrototypeOf(this, OpenFeatureError.prototype); + this.name = 'OpenFeatureError'; + this.cause = options?.cause; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts new file mode 100644 index 0000000000..2d948077df --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ParseError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ParseError.prototype); + this.name = 'ParseError'; + this.code = ErrorCode.PARSE_ERROR; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts new file mode 100644 index 0000000000..1d9fd8a2bd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ProviderFatalError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ProviderFatalError.prototype); + this.name = 'ProviderFatalError'; + this.code = ErrorCode.PROVIDER_FATAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts new file mode 100644 index 0000000000..02fd2cf48d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ProviderNotReadyError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ProviderNotReadyError.prototype); + this.name = 'ProviderNotReadyError'; + this.code = ErrorCode.PROVIDER_NOT_READY; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts new file mode 100644 index 0000000000..dcd4e04943 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class TargetingKeyMissingError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, TargetingKeyMissingError.prototype); + this.name = 'TargetingKeyMissingError'; + this.code = ErrorCode.TARGETING_KEY_MISSING; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts new file mode 100644 index 0000000000..80b3b87b48 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class TypeMismatchError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, TypeMismatchError.prototype); + this.name = 'TypeMismatchError'; + this.code = ErrorCode.TYPE_MISMATCH; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts new file mode 100644 index 0000000000..db4f9597cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts @@ -0,0 +1,36 @@ +import type { PrimitiveValue } from '../types'; + +export type EvaluationContextValue = + | PrimitiveValue + | Date + | { [key: string]: EvaluationContextValue } + | EvaluationContextValue[]; + +/** + * A container for arbitrary contextual data that can be used as a basis for dynamic evaluation + */ +export type EvaluationContext = { + /** + * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. + * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. + * Such providers may behave unpredictably if a targeting key is not specified at flag resolution. + */ + targetingKey?: string; +} & Record; + +export interface ManageContext { + /** + * Access the evaluation context set on the receiver. + * @returns {EvaluationContext} Evaluation context + */ + getContext(): EvaluationContext; + + /** + * Sets evaluation context that will be used during flag evaluations + * on this receiver. + * @template T The type of the receiver + * @param {EvaluationContext} context Evaluation context + * @returns {T} The receiver (this object) + */ + setContext(context: EvaluationContext): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts new file mode 100644 index 0000000000..78e7b65cbb --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts @@ -0,0 +1,122 @@ +import type { JsonValue } from '../types/structure'; + +export type FlagValueType = 'boolean' | 'string' | 'number' | 'object'; + +/** + * Represents a JSON node value. + */ +export type FlagValue = boolean | string | number | JsonValue; + +export type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); + +/** + * A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + * + * This structure is populated by a provider for use by an Application Author (via the Evaluation API) or an Application Integrator (via hooks). + */ +export type FlagMetadata = Record; + +export type ResolutionDetails = { + value: U; + variant?: string; + flagMetadata?: FlagMetadata; + reason?: ResolutionReason; + errorCode?: ErrorCode; + errorMessage?: string; +}; + +export type EvaluationDetails = { + flagKey: string; + flagMetadata: Readonly; +} & ResolutionDetails; + +export const StandardResolutionReasons = { + /** + * The resolved value is static (no dynamic evaluation). + */ + STATIC: 'STATIC', + + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + DEFAULT: 'DEFAULT', + + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + TARGETING_MATCH: 'TARGETING_MATCH', + + /** + * The resolved value was the result of pseudorandom assignment. + */ + SPLIT: 'SPLIT', + + /** + * The resolved value was retrieved from cache. + */ + CACHED: 'CACHED', + + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + DISABLED: 'DISABLED', + + /** + * The reason for the resolved value could not be determined. + */ + UNKNOWN: 'UNKNOWN', + + /** + * The resolved value is non-authoritative or possibly out of date. + */ + STALE: 'STALE', + + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + ERROR: 'ERROR', +} as const; + +export enum ErrorCode { + /** + * The value was resolved before the provider was ready. + */ + PROVIDER_NOT_READY = 'PROVIDER_NOT_READY', + + /** + * The provider has entered an irrecoverable error state. + */ + PROVIDER_FATAL = 'PROVIDER_FATAL', + + /** + * The flag could not be found. + */ + FLAG_NOT_FOUND = 'FLAG_NOT_FOUND', + + /** + * An error was encountered parsing data, such as a flag configuration. + */ + PARSE_ERROR = 'PARSE_ERROR', + + /** + * The type of the flag value does not match the expected type. + */ + TYPE_MISMATCH = 'TYPE_MISMATCH', + + /** + * The provider requires a targeting key and one was not provided in the evaluation context. + */ + TARGETING_KEY_MISSING = 'TARGETING_KEY_MISSING', + + /** + * The evaluation context does not meet provider requirements. + */ + INVALID_CONTEXT = 'INVALID_CONTEXT', + + /** + * An error with an unspecified code. + */ + GENERAL = 'GENERAL', +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts new file mode 100644 index 0000000000..3f94fc79a8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './evaluation'; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts b/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts new file mode 100644 index 0000000000..c840eef2ea --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts @@ -0,0 +1,27 @@ +import type { ClientProviderStatus, ServerProviderStatus } from '../provider'; +import { AllProviderStatus } from '../provider'; +import type { AnyProviderEvent } from './events'; +import { AllProviderEvents } from './events'; + +const eventStatusMap = { + [AllProviderStatus.READY]: AllProviderEvents.Ready, + [AllProviderStatus.ERROR]: AllProviderEvents.Error, + [AllProviderStatus.FATAL]: AllProviderEvents.Error, + [AllProviderStatus.STALE]: AllProviderEvents.Stale, + [AllProviderStatus.RECONCILING]: AllProviderEvents.Reconciling, + [AllProviderStatus.NOT_READY]: undefined, +}; + +/** + * Returns true if the provider's status corresponds to the event. + * If the provider's status is not defined, it matches READY. + * @param {AnyProviderEvent} event event to match + * @param {ClientProviderStatus | ServerProviderStatus} status status of provider + * @returns {boolean} boolean indicating if the provider status corresponds to the event. + */ +export const statusMatchesEvent = ( + event: T, + status?: ClientProviderStatus | ServerProviderStatus, +): boolean => { + return (!status && event === AllProviderEvents.Ready) || eventStatusMap[status!] === event; +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts b/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts new file mode 100644 index 0000000000..952b6e1dc2 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts @@ -0,0 +1,113 @@ +import type { ErrorCode } from '../evaluation'; +import type { ClientProviderEvents, ServerProviderEvents } from './events'; +import { AnyProviderEvent } from './events'; + +export type EventMetadata = { + [key: string]: string | boolean | number; +}; + +export type CommonEventDetails = { + readonly providerName: string; + /** + * @deprecated alias of "domain", use domain instead + */ + readonly clientName?: string; + readonly domain?: string; +}; + +type CommonEventProps = { + readonly message?: string; + readonly metadata?: EventMetadata; +}; + +export type ReadyEvent = CommonEventProps; +export type ErrorEvent = CommonEventProps; +export type StaleEvent = CommonEventProps; +export type ReconcilingEvent = CommonEventProps & { readonly errorCode: ErrorCode }; +export type ConfigChangeEvent = CommonEventProps & { readonly flagsChanged?: string[] }; + +type ServerEventMap = { + [ServerProviderEvents.Ready]: ReadyEvent; + [ServerProviderEvents.Error]: ErrorEvent; + [ServerProviderEvents.Stale]: StaleEvent; + [ServerProviderEvents.ConfigurationChanged]: ConfigChangeEvent; +}; + +type ClientEventMap = { + [ClientProviderEvents.Ready]: ReadyEvent; + [ClientProviderEvents.Error]: ErrorEvent; + [ClientProviderEvents.Stale]: StaleEvent; + [ClientProviderEvents.ConfigurationChanged]: ConfigChangeEvent; + [ClientProviderEvents.Reconciling]: CommonEventProps; + [ClientProviderEvents.ContextChanged]: CommonEventProps; +}; + +type ServerNotChangeEvents = ServerProviderEvents.Ready | ServerProviderEvents.Error | ServerProviderEvents.Stale; +type ClientNotChangeEvents = + | ClientProviderEvents.Ready + | ClientProviderEvents.Error + | ClientProviderEvents.Stale + | ClientProviderEvents.ContextChanged + | ClientProviderEvents.Reconciling; +export type NotChangeEvents = ServerNotChangeEvents | ClientNotChangeEvents; + +export type EventContext< + U extends Record = Record, + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = (T extends ClientProviderEvents ? ClientEventMap[T] : T extends ServerProviderEvents ? ServerEventMap[T] : never) & + U; + +export type EventDetails< + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = EventContext, T> & CommonEventDetails; +export type EventHandler< + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = (eventDetails?: EventDetails) => Promise | unknown; +export type EventOptions = { + signal?: AbortSignal; +}; + +export interface Eventing { + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * @param eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler( + eventType: T extends ClientProviderEvents + ? ClientProviderEvents.ConfigurationChanged + : ServerProviderEvents.ConfigurationChanged, + handler: EventHandler< + T extends ClientProviderEvents + ? ClientProviderEvents.ConfigurationChanged + : ServerProviderEvents.ConfigurationChanged + >, + options?: EventOptions, + ): void; + addHandler( + eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents, + handler: EventHandler, + options?: EventOptions, + ): void; + addHandler( + eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents, + handler: EventHandler, + options?: EventOptions, + ): void; + + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/events/events.ts b/tests/fixtures/source-overlays/sources/core/src/events/events.ts new file mode 100644 index 0000000000..e32f6d564f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/events.ts @@ -0,0 +1,73 @@ +// TODO: with TypeScript 5+, we can use computed string properties, +// so we can extract all of these into a shared set of string consts and use that in both enums +// for now we have duplicated them. + +/** + * An enumeration of possible events for server-sdk providers. + */ +export enum ServerProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = 'PROVIDER_READY', + + /** + * The provider is in an error state. + */ + Error = 'PROVIDER_ERROR', + + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = 'PROVIDER_CONFIGURATION_CHANGED', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = 'PROVIDER_STALE', +} + +/** + * An enumeration of possible events for web-sdk providers. + */ +export enum ClientProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = 'PROVIDER_READY', + + /** + * The provider is in an error state. + */ + Error = 'PROVIDER_ERROR', + + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = 'PROVIDER_CONFIGURATION_CHANGED', + + /** + * The context associated with the provider has changed, and the provider has reconciled it's associated state. + */ + ContextChanged = 'PROVIDER_CONTEXT_CHANGED', + + /** + * The context associated with the provider has changed, and the provider has not yet reconciled its associated state. + */ + Reconciling = 'PROVIDER_RECONCILING', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = 'PROVIDER_STALE', +} + +/* alias because in many cases, we iterate over all possible events in code, +so we have to do this on ClientProviderEvents to be exhaustive */ +export { ClientProviderEvents as AllProviderEvents }; + +/** + * A type representing any possible ProviderEvent (server or client side). + * In most cases, you probably want to import `ProviderEvents` from the respective SDK. + */ +export type AnyProviderEvent = ServerProviderEvents | ClientProviderEvents; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts b/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts new file mode 100644 index 0000000000..915470a4bd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts @@ -0,0 +1,110 @@ +import type { Logger, ManageLogger } from '../logger'; +import { SafeLogger } from '../logger'; +import type { ProviderEventEmitter } from './provider-event-emitter'; +import type { EventContext, EventDetails, EventHandler } from './eventing'; +import type { AnyProviderEvent } from './events'; +import { AllProviderEvents } from './events'; + +/** + * The GenericEventEmitter should only be used within the SDK. It supports additional properties that can be included + * in the event details. + */ +export abstract class GenericEventEmitter< + E extends AnyProviderEvent, + AdditionalContext extends Record = Record, +> + implements ProviderEventEmitter, ManageLogger> +{ + protected abstract readonly eventEmitter: PlatformEventEmitter; + + private readonly _handlers: { [key in AnyProviderEvent]: WeakMap } = { + [AllProviderEvents.ConfigurationChanged]: new WeakMap(), + [AllProviderEvents.ContextChanged]: new WeakMap(), + [AllProviderEvents.Ready]: new WeakMap(), + [AllProviderEvents.Error]: new WeakMap(), + [AllProviderEvents.Stale]: new WeakMap(), + [AllProviderEvents.Reconciling]: new WeakMap(), + }; + private _eventLogger?: Logger; + + constructor(private readonly globalLogger?: () => Logger) {} + + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType: E, context?: EventContext): void { + this.eventEmitter.emit(eventType, context); + } + + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void { + // The handlers have to be wrapped with an async function because if a synchronous functions throws an error, + // the other handlers will not run. + const asyncHandler = async (details?: EventDetails) => { + try { + await handler(details); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + }; + // The async handler has to be written to the map, because we need to get the wrapper function when deleting a listener + const existingAsyncHandlers = this._handlers[eventType].get(handler); + + // we allow duplicate event handlers, similar to node, + // see: https://nodejs.org/api/events.html#emitteroneventname-listener + // and https://nodejs.org/api/events.html#emitterremovelistenereventname-listener + this._handlers[eventType].set(handler, [...(existingAsyncHandlers || []), asyncHandler]); + this.eventEmitter.on(eventType, asyncHandler); + } + + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void { + // Get the wrapper function for this handler, to delete it from the event emitter + const existingAsyncHandlers = this._handlers[eventType].get(handler); + + if (existingAsyncHandlers) { + const removedAsyncHandler = existingAsyncHandlers.pop(); + if (removedAsyncHandler) { + this.eventEmitter.removeListener(eventType, removedAsyncHandler); + } + } + } + + removeAllHandlers(eventType?: AnyProviderEvent): void { + // If giving undefined, the listeners are not removed, so we have to check explicitly + if (eventType) { + this.eventEmitter.removeAllListeners(eventType); + } else { + this.eventEmitter.removeAllListeners(); + } + } + + getHandlers(eventType: AnyProviderEvent): EventHandler[] { + return this.eventEmitter.listeners(eventType) as EventHandler[]; + } + + setLogger(logger: Logger): this { + this._eventLogger = new SafeLogger(logger); + return this; + } + + protected get _logger() { + return this._eventLogger ?? this.globalLogger?.(); + } +} + +/** + * This is an un-exported type that corresponds to NodeJS.EventEmitter. + * We can't use that type here, because this module is used in both the browser, and the server. + * In the server, node (or whatever server runtime) provides an implementation for this. + * In the browser, we bundle in the popular 'EventEmitter3' package, which is a polyfill of NodeJS.EventEmitter. + */ +/* eslint-disable */ +interface PlatformEventEmitter { + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + on(eventName: string | symbol, listener: (...args: any[]) => void): this; + once(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + off(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeAllListeners(event?: string | symbol): this; + listeners(eventName: string | symbol): Function[]; + emit(eventName: string | symbol, ...args: any[]): boolean; + listenerCount(eventName: string | symbol, listener?: Function): number; + eventNames(): Array; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/events/index.ts b/tests/fixtures/source-overlays/sources/core/src/events/index.ts new file mode 100644 index 0000000000..1e811b6907 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/index.ts @@ -0,0 +1,5 @@ +export * from './provider-event-emitter'; +export * from './event-utils'; +export * from './eventing'; +export * from './events'; +export * from './generic-event-emitter'; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts b/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts new file mode 100644 index 0000000000..cec0088c99 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts @@ -0,0 +1,23 @@ +import type { ManageLogger } from '../logger'; +import type { EventContext, EventHandler } from './eventing'; +import type { AnyProviderEvent } from './events'; + +/** + * Event emitter to be optionally implemented by providers. + * Implemented by @see OpenFeatureEventEmitter. + */ +export interface ProviderEventEmitter< + E extends AnyProviderEvent, + AdditionalContext extends Record = Record, +> extends ManageLogger> { + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType: E, context?: EventContext): void; + + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + + removeAllHandlers(eventType?: AnyProviderEvent): void; + + getHandlers(eventType: AnyProviderEvent): EventHandler[]; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/filter.ts b/tests/fixtures/source-overlays/sources/core/src/filter.ts new file mode 100644 index 0000000000..f0ab489fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/filter.ts @@ -0,0 +1,9 @@ +/** + * Checks if a value is not null or undefined and returns it as type assertion + * @template T + * @param {T} input The value to check + * @returns {T} If the value is not null or undefined + */ +export function isDefined(input?: T | null | undefined): input is T { + return typeof input !== 'undefined' && input !== null; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts new file mode 100644 index 0000000000..361f9c3de9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts @@ -0,0 +1,28 @@ +import type { BaseHook } from './hook'; + +export interface EvaluationLifeCycle { + /** + * Adds hooks that will run during flag evaluations on this receiver. + * Hooks are executed in the order they were registered. Adding additional hooks + * will not remove existing hooks. + * Hooks registered on the global API object run with all evaluations. + * Hooks registered on the client run with all evaluations on that client. + * @template T The type of the receiver + * @param {BaseHook[]} hooks A list of hooks that should always run + * @returns {T} The receiver (this object) + */ + addHooks(...hooks: BaseHook[]): T; + + /** + * Access all the hooks that are registered on this receiver. + * @returns {BaseHook[]} A list of the client hooks + */ + getHooks(): BaseHook[]; + + /** + * Clears all the hooks that are registered on this receiver. + * @template T The type of the receiver + * @returns {T} The receiver (this object) + */ + clearHooks(): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts new file mode 100644 index 0000000000..afd9f9f3c4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts @@ -0,0 +1,72 @@ +/** + * A mutable data structure for hooks to maintain state across their lifecycle. + * Each hook instance gets its own isolated data store that persists for the + * duration of a single flag evaluation. + * @template TData - A record type that defines the shape of the stored data + */ +export interface HookData> { + /** + * Sets a value in the hook data store. + * @param key The key to store the value under + * @param value The value to store + */ + set(key: K, value: TData[K]): void; + set(key: string, value: unknown): void; + + /** + * Gets a value from the hook data store. + * @param key The key to retrieve the value for + * @returns The stored value, or undefined if not found + */ + get(key: K): TData[K] | undefined; + get(key: string): unknown; + + /** + * Checks if a key exists in the hook data store. + * @param key The key to check + * @returns True if the key exists, false otherwise + */ + has(key: K): boolean; + has(key: string): boolean; + + /** + * Deletes a value from the hook data store. + * @param key The key to delete + * @returns True if the key was deleted, false if it didn't exist + */ + delete(key: K): boolean; + delete(key: string): boolean; + + /** + * Clears all values from the hook data store. + */ + clear(): void; +} + +/** + * Default implementation of HookData using a Map. + * @template TData - A record type that defines the shape of the stored data + */ +export class MapHookData> implements HookData { + private readonly data = new Map(); + + set(key: K, value: TData[K]): void { + this.data.set(key, value); + } + + get(key: K): TData[K] | undefined { + return this.data.get(key) as TData[K] | undefined; + } + + has(key: K): boolean { + return this.data.has(key); + } + + delete(key: K): boolean { + return this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts new file mode 100644 index 0000000000..3c21c04dac --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts @@ -0,0 +1,49 @@ +import type { BeforeHookContext, HookContext, HookHints } from './hooks'; +import type { EvaluationDetails, FlagValue } from '../evaluation'; + +export interface BaseHook< + T extends FlagValue = FlagValue, + TData = Record, + BeforeHookReturn = unknown, + HooksReturn = unknown, +> { + /** + * Runs before flag values are resolved from the provider. + * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. + * @param hookContext + * @param hookHints + */ + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + + /** + * Runs after flag values are successfully resolved from the provider. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + after?( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + hookHints?: HookHints, + ): HooksReturn; + + /** + * Runs in the event of an unhandled error or promise rejection during flag resolution, or any attached hooks. + * @param hookContext + * @param error + * @param hookHints + */ + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; + + /** + * Runs after all other hook stages, regardless of success or error. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + finally?( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + hookHints?: HookHints, + ): HooksReturn; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts new file mode 100644 index 0000000000..58e0e553dd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts @@ -0,0 +1,25 @@ +import type { ProviderMetadata } from '../provider'; +import type { ClientMetadata } from '../client'; +import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation'; +import type { Logger } from '../logger'; +import type { HookData } from './hook-data'; + +export type HookHints = Readonly>; + +export interface HookContext> { + readonly flagKey: string; + readonly defaultValue: T; + readonly flagValueType: FlagValueType; + readonly context: Readonly; + readonly clientMetadata: ClientMetadata; + readonly providerMetadata: ProviderMetadata; + readonly logger: Logger; + readonly hookData: HookData; +} + +export interface BeforeHookContext< + T extends FlagValue = FlagValue, + TData = Record, +> extends HookContext { + context: EvaluationContext; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts new file mode 100644 index 0000000000..47227681a6 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './hook'; +export * from './hooks'; +export * from './evaluation-lifecycle'; +export * from './hook-data'; diff --git a/tests/fixtures/source-overlays/sources/core/src/index.ts b/tests/fixtures/source-overlays/sources/core/src/index.ts new file mode 100644 index 0000000000..c708fb9afd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/index.ts @@ -0,0 +1,12 @@ +export * from './hooks'; +export * from './types'; +export * from './client'; +export * from './errors'; +export * from './events'; +export * from './logger'; +export * from './telemetry'; +export * from './provider'; +export * from './evaluation'; +export * from './type-guards'; +export * from './tracking'; +export * from './open-feature'; diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts new file mode 100644 index 0000000000..111ef61259 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import type { Logger } from './logger'; + +export class DefaultLogger implements Logger { + error(...args: unknown[]): void { + console.error(...args); + } + + warn(...args: unknown[]): void { + console.warn(...args); + } + + info(): void {} + + debug(): void {} +} diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/index.ts b/tests/fixtures/source-overlays/sources/core/src/logger/index.ts new file mode 100644 index 0000000000..2c09cfaf44 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/index.ts @@ -0,0 +1,3 @@ +export * from './logger'; +export * from './default-logger'; +export * from './safe-logger'; diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts new file mode 100644 index 0000000000..04d9c06b82 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts @@ -0,0 +1,22 @@ +export interface Logger { + error(...args: unknown[]): void; + + warn(...args: unknown[]): void; + + info(...args: unknown[]): void; + + debug(...args: unknown[]): void; +} + +export interface ManageLogger { + /** + * Sets a logger on this receiver. This logger supersedes to the global logger + * and is passed to various components in the SDK. + * The logger configured on the global API object will be used for all evaluations, + * unless overridden in a particular client. + * @template T The type of the receiver + * @param {Logger} logger The logger to be used + * @returns {T} The receiver (this object) + */ + setLogger(logger: Logger): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts new file mode 100644 index 0000000000..a860f3828c --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts @@ -0,0 +1,48 @@ +import type { Logger } from './logger'; +import { DefaultLogger } from './default-logger'; + +export const LOG_LEVELS: Array = ['error', 'warn', 'info', 'debug']; + +export class SafeLogger implements Logger { + private readonly logger: Logger; + private readonly fallbackLogger = new DefaultLogger(); + + constructor(logger: Logger) { + try { + for (const level of LOG_LEVELS) { + if (!logger[level] || typeof logger[level] !== 'function') { + throw new Error(`The provided logger is missing the ${level} method.`); + } + } + this.logger = logger; + } catch (err) { + console.error(err); + console.error('Falling back to the default logger.'); + this.logger = this.fallbackLogger; + } + } + + error(...args: unknown[]): void { + this.log('error', ...args); + } + + warn(...args: unknown[]): void { + this.log('warn', ...args); + } + + info(...args: unknown[]): void { + this.log('info', ...args); + } + + debug(...args: unknown[]): void { + this.log('debug', ...args); + } + + private log(level: keyof Logger, ...args: unknown[]) { + try { + this.logger[level](...args); + } catch (error) { + this.fallbackLogger[level](...args); + } + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/open-feature.ts b/tests/fixtures/source-overlays/sources/core/src/open-feature.ts new file mode 100644 index 0000000000..00142b5670 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/open-feature.ts @@ -0,0 +1,443 @@ +import type { OpenFeatureError } from './errors'; +import { GeneralError } from './errors'; +import type { EvaluationContext } from './evaluation'; +import { ErrorCode } from './evaluation'; +import type { + AnyProviderEvent, + EventDetails, + EventHandler, + Eventing, + EventOptions, + GenericEventEmitter, +} from './events'; +import { AllProviderEvents, statusMatchesEvent } from './events'; +import { isDefined } from './filter'; +import type { BaseHook, EvaluationLifeCycle } from './hooks'; +import type { Logger, ManageLogger } from './logger'; +import { DefaultLogger, SafeLogger } from './logger'; +import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider'; +import { objectOrUndefined, stringOrUndefined } from './type-guards'; +import type { Paradigm } from './types'; + +type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; + +/** + * A provider and its current status. + * For internal use only. + */ +export class ProviderWrapper

, S extends AnyProviderStatus> { + private _pendingContextChanges = 0; + + constructor( + private _provider: P, + private _status: S, + _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus, + ) { + // update the providers status with events + _provider.events?.addHandler(AllProviderEvents.Ready, () => { + // These casts are due to the face we don't "know" what status enum we are dealing with here (client or server). + // We could abstract this an implement it in the client/server libs to fix this, but the value is low. + this._status = _statusEnumType.READY as S; + }); + _provider.events?.addHandler(AllProviderEvents.Stale, () => { + this._status = _statusEnumType.STALE as S; + }); + _provider.events?.addHandler(AllProviderEvents.Error, (details) => { + if (details?.errorCode === ErrorCode.PROVIDER_FATAL) { + this._status = _statusEnumType.FATAL as S; + } else { + this._status = _statusEnumType.ERROR as S; + } + }); + } + + get provider(): P { + return this._provider; + } + + set provider(provider: P) { + this._provider = provider; + } + + get status(): S { + return this._status; + } + + set status(status: S) { + this._status = status; + } + + get allContextChangesSettled() { + return this._pendingContextChanges === 0; + } + + incrementPendingContextChanges() { + this._pendingContextChanges++; + } + + decrementPendingContextChanges() { + this._pendingContextChanges--; + } +} + +export abstract class OpenFeatureCommonAPI< + S extends AnyProviderStatus, + P extends CommonProvider = CommonProvider, + H extends BaseHook = BaseHook, +> + implements + Eventing, + EvaluationLifeCycle>, + ManageLogger> +{ + // accessor for the type of the ProviderStatus enum (client or server) + protected abstract readonly _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus; + protected abstract _createEventEmitter(): GenericEventEmitter; + protected abstract _defaultProvider: ProviderWrapper; + protected abstract readonly _domainScopedProviders: Map>; + protected abstract readonly _apiEmitter: GenericEventEmitter; + + protected _hooks: H[] = []; + protected _context: EvaluationContext = {}; + protected _logger: Logger = new DefaultLogger(); + + private readonly _clientEventHandlers: Map = new Map(); + protected _domainScopedContext: Map = new Map(); + protected _clientEvents: Map> = new Map(); + protected _runsOn: Paradigm; + + constructor(category: Paradigm) { + this._runsOn = category; + } + + addHooks(...hooks: H[]): this { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + + getHooks(): H[] { + return this._hooks; + } + + clearHooks(): this { + this._hooks = []; + return this; + } + + setLogger(logger: Logger): this { + this._logger = new SafeLogger(logger); + return this; + } + + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata(): ProviderMetadata { + return this.getProviderMetadata(); + } + + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain?: string): ProviderMetadata { + return this.getProviderForClient(domain).metadata; + } + + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T, handler: EventHandler, options?: EventOptions): void { + [...new Map([[undefined, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => { + const domain = keyProviderTuple[0]; + const provider = keyProviderTuple[1].provider; + const status = keyProviderTuple[1].status; + const shouldRunNow = statusMatchesEvent(eventType, status); + + if (shouldRunNow) { + // run immediately, we're in the matching state + try { + handler({ domain, providerName: provider.metadata.name }); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + } + }); + + this._apiEmitter.addHandler(eventType, handler); + if (options?.signal && typeof options.signal.addEventListener === 'function') { + options.signal.addEventListener('abort', () => { + this.removeHandler(eventType, handler); + }); + } + } + + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void { + this._apiEmitter.removeHandler(eventType, handler); + } + + /** + * Removes all event handlers. + */ + clearHandlers(): void { + this._apiEmitter.removeAllHandlers(); + } + + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[] { + return this._apiEmitter.getHandlers(eventType); + } + + abstract setProviderAndWait( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Promise; + + abstract setProvider( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): this; + + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { + const domain = stringOrUndefined(domainOrProvider); + const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); + + if (!provider) { + this._logger.debug('No provider defined, ignoring setProvider call'); + return; + } + + const oldProvider = this.getProviderForClient(domain); + const providerName = provider.metadata.name; + + // ignore no-ops + if (oldProvider === provider) { + this._logger.debug('Provider is already set, ignoring setProvider call'); + return; + } + + if (!provider.runsOn) { + this._logger.debug(`Provider '${provider.metadata.name}' has not defined its intended use.`); + } else if (provider.runsOn !== this._runsOn) { + throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`); + } + + const emitters = this.getAssociatedEventEmitters(domain); + + let initializationPromise: Promise | void = undefined; + const wrappedProvider = new ProviderWrapper( + provider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); + + // initialize the provider if it implements "initialize" and it's not already registered + if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) { + initializationPromise = provider + .initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) + ?.then(() => { + wrappedProvider.status = this._statusEnumType.READY; + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }) + ?.catch((error) => { + // if this is a fatal error, transition to FATAL status + if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { + wrappedProvider.status = this._statusEnumType.FATAL; + } else { + wrappedProvider.status = this._statusEnumType.ERROR; + } + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: error?.message, + }); + }); + this._apiEmitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: error?.message, + }); + // rethrow after emitting error events, so that public methods can control error handling + throw error; + }); + } else { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + } + + if (domain) { + this._domainScopedProviders.set(domain, wrappedProvider); + } else { + this._defaultProvider = wrappedProvider; + } + + this.transferListeners(oldProvider, provider, domain, emitters); + + // Do not close a provider that is bound to any client + if (!this.allProviders.includes(oldProvider)) { + oldProvider?.onClose?.()?.catch((err: Error | undefined) => { + this._logger.error(`error closing provider: ${err?.message}, ${err?.stack}`); + }); + } + + return initializationPromise; + } + + protected getProviderForClient(domain?: string): P { + if (!domain) { + return this._defaultProvider.provider; + } + + return this._domainScopedProviders.get(domain)?.provider ?? this._defaultProvider.provider; + } + + protected buildAndCacheEventEmitterForClient(domain?: string): GenericEventEmitter { + const emitter = this._clientEvents.get(domain); + + if (emitter) { + return emitter; + } + + // lazily add the event emitters + const newEmitter = this._createEventEmitter(); + this._clientEvents.set(domain, newEmitter); + + const clientProvider = this.getProviderForClient(domain); + Object.values(AllProviderEvents).forEach((eventType) => + clientProvider.events?.addHandler(eventType, async (details) => { + newEmitter.emit(eventType, { + ...details, + clientName: domain, + domain, + providerName: clientProvider.metadata.name, + }); + }), + ); + + return newEmitter; + } + + private getUnboundEmitters(): GenericEventEmitter[] { + const domainScopedProviders = [...this._domainScopedProviders.keys()]; + const eventEmitterNames = [...this._clientEvents.keys()].filter(isDefined); + const unboundEmitterNames = eventEmitterNames.filter((name) => !domainScopedProviders.includes(name)); + return [ + // all unbound, named emitters + ...unboundEmitterNames.map((name) => this._clientEvents.get(name)), + // the default emitter + this._clientEvents.get(undefined), + ].filter(isDefined); + } + + protected getAssociatedEventEmitters(domain: string | undefined) { + return domain ? [this.buildAndCacheEventEmitterForClient(domain)] : this.getUnboundEmitters(); + } + + private transferListeners( + oldProvider: P, + newProvider: P, + domain: string | undefined, + emitters: (GenericEventEmitter | undefined)[], + ) { + this._clientEventHandlers + .get(domain) + ?.forEach((eventHandler) => oldProvider.events?.removeHandler(...eventHandler)); + + // iterate over the event types + const newClientHandlers = Object.values(AllProviderEvents).map<[AllProviderEvents, EventHandler]>((eventType) => { + const handler = async (details?: EventDetails) => { + // on each event type, fire the associated handlers + emitters.forEach((emitter) => { + emitter?.emit(eventType, { ...details, clientName: domain, domain, providerName: newProvider.metadata.name }); + }); + this._apiEmitter.emit(eventType, { + ...details, + clientName: domain, + domain, + providerName: newProvider.metadata.name, + }); + }; + + return [eventType, handler]; + }); + + this._clientEventHandlers.set(domain, newClientHandlers); + newClientHandlers.forEach((eventHandler) => newProvider.events?.addHandler(...eventHandler)); + } + + async close(): Promise { + try { + await this?._defaultProvider.provider?.onClose?.(); + } catch (err) { + this.handleShutdownError(this._defaultProvider.provider, err); + } + + const wrappers = Array.from(this._domainScopedProviders); + + await Promise.all( + wrappers.map(async ([, wrapper]) => { + try { + await wrapper?.provider.onClose?.(); + } catch (err) { + this.handleShutdownError(wrapper?.provider, err); + } + }), + ); + } + + protected async clearProvidersAndSetDefault(defaultProvider: P): Promise { + try { + await this.close(); + } catch (err) { + this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.'); + } finally { + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); + } + } + + private get allProviders(): P[] { + return [ + ...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider), + this._defaultProvider.provider, + ]; + } + + private handleShutdownError(provider: P, err: unknown) { + this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`); + this._logger.error((err as Error)?.stack); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/index.ts b/tests/fixtures/source-overlays/sources/core/src/provider/index.ts new file mode 100644 index 0000000000..952bd5e772 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/index.ts @@ -0,0 +1,5 @@ +export * from './provider'; +export * from './multi-provider/errors'; +export * from './multi-provider/status-tracker'; +export * from './multi-provider/types'; +export * from './multi-provider/strategies'; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts new file mode 100644 index 0000000000..25f5c53ca8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts @@ -0,0 +1,57 @@ +import type { ErrorCode } from '../../evaluation'; +import { GeneralError, OpenFeatureError } from '../../errors'; +import type { RegisteredProvider } from './types'; + +export class ErrorWithCode extends OpenFeatureError { + constructor( + public code: ErrorCode, + message: string, + ) { + super(message); + } +} + +export class AggregateError extends GeneralError { + constructor( + message: string, + public originalErrors: { source: string; error: unknown }[], + ) { + super(message); + Object.setPrototypeOf(this, AggregateError.prototype); + this.name = 'AggregateError'; + } +} + +export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { + const errorsWithSource = providerErrors + .map(({ providerName, error }) => { + return { source: providerName, error }; + }) + .flat(); + + // log first error in the message for convenience, but include all errors in the error object for completeness + const firstError = errorsWithSource[0]; + const message = firstError + ? `Provider errors occurred: ${firstError.source}: ${firstError.error}` + : 'Provider errors occurred'; + + return new AggregateError(message, errorsWithSource); +}; + +export const throwAggregateErrorFromPromiseResults = ( + result: PromiseSettledResult[], + providerEntries: RegisteredProvider[], +) => { + const errors = result + .map((r, i) => { + if (r.status === 'rejected') { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }) + .filter((val): val is { error: unknown; providerName: string } => Boolean(val)); + + if (errors.length) { + throw constructAggregateError(errors); + } +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts new file mode 100644 index 0000000000..ccd38a63ad --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts @@ -0,0 +1,113 @@ +import type { EventDetails, ProviderEventEmitter, AnyProviderEvent } from '../../events'; +import type { RegisteredProvider } from './types'; + +/** + * Interface for the provider events enum that StatusTracker expects. + * Must have at least Error, Stale, ConfigurationChanged, and Ready events. + * Reconciling is optional (web only). + */ +type ProviderEventsEnum = { + Error: T; + Stale: T; + ConfigurationChanged: T; + Ready: T; + Reconciling?: T; +}; + +/** + * Interface for the provider status enum that StatusTracker expects. + */ +type ProviderStatusEnum = { + NOT_READY: T; + READY: T; + ERROR: T; + STALE: T; + FATAL: T; + RECONCILING?: T; +}; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker< + TProviderEvents extends AnyProviderEvent, + TProviderStatus, + TProvider extends { events?: ProviderEventEmitter }, +> { + private readonly providerStatuses: Record = {}; + + constructor( + private events: ProviderEventEmitter, + private statusEnum: ProviderStatusEnum, + private eventEnum: ProviderEventsEnum, + ) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(this.eventEnum.Error, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.ERROR, details); + }); + + provider.events?.addHandler(this.eventEnum.Stale, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.STALE, details); + }); + + provider.events?.addHandler(this.eventEnum.ConfigurationChanged, (details?: EventDetails) => { + this.events.emit(this.eventEnum.ConfigurationChanged, details); + }); + + provider.events?.addHandler(this.eventEnum.Ready, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.READY, details); + }); + + // Handle Reconciling event (web only - server doesn't have this) + const reconcilingEvent = this.eventEnum.Reconciling; + const reconcilingStatus = this.statusEnum.RECONCILING; + if (reconcilingEvent && reconcilingStatus) { + provider.events?.addHandler(reconcilingEvent, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, reconcilingStatus, details); + }); + } + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(this.statusEnum.FATAL)) { + return this.statusEnum.FATAL; + } else if (statuses.includes(this.statusEnum.NOT_READY)) { + return this.statusEnum.NOT_READY; + } else if (statuses.includes(this.statusEnum.ERROR)) { + return this.statusEnum.ERROR; + } else if (statuses.includes(this.statusEnum.STALE)) { + return this.statusEnum.STALE; + } else if (this.statusEnum.RECONCILING && statuses.includes(this.statusEnum.RECONCILING)) { + return this.statusEnum.RECONCILING; + } + return this.statusEnum.READY; + } + + private changeProviderStatus(name: string, status: TProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === this.statusEnum.FATAL || newStatus === this.statusEnum.ERROR) { + this.events.emit(this.eventEnum.Error, details); + } else if (newStatus === this.statusEnum.STALE) { + this.events.emit(this.eventEnum.Stale, details); + } else if (newStatus === this.statusEnum.READY) { + this.events.emit(this.eventEnum.Ready, details); + } else { + const reconcilingEvent = this.eventEnum.Reconciling; + if (reconcilingEvent && this.statusEnum.RECONCILING && newStatus === this.statusEnum.RECONCILING) { + this.events.emit(reconcilingEvent, details); + } + } + } + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts new file mode 100644 index 0000000000..d35094ddec --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -0,0 +1,152 @@ +import type { ErrorCode, EvaluationContext, FlagValue, FlagValueType, ResolutionDetails } from '../../../evaluation'; +import type { OpenFeatureError } from '../../../errors'; +import { ErrorWithCode } from '../errors'; +import type { TrackingEventDetails } from '../../../tracking'; + +export type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; + +export type BaseStrategyProviderContext = { + provider: TProvider; + providerName: string; + providerStatus: TProviderStatus; +}; + +export type BaseStrategyPerProviderContext = StrategyEvaluationContext & + BaseStrategyProviderContext; + +type BaseProviderResolutionResultBase = { + provider: TProvider; + providerName: string; +}; + +export type BaseProviderResolutionSuccessResult< + T extends FlagValue, + _TProviderStatus, + TProvider, +> = BaseProviderResolutionResultBase & { + details: ResolutionDetails; +}; + +export type BaseProviderResolutionErrorResult<_TProviderStatus, TProvider> = + BaseProviderResolutionResultBase & { + thrownError: unknown; + }; + +export type BaseProviderResolutionResult = + | BaseProviderResolutionSuccessResult + | BaseProviderResolutionErrorResult; + +export type BaseFinalResult = { + details?: ResolutionDetails; + provider?: TProvider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; + +/** + * Base evaluation strategy for multi-provider flag resolution. + * + * This class is intended to be extended by concrete strategies and is not + * directly usable on its own. Implementations must provide a + * `determineFinalResult` method that takes the per-provider results and + * determines the final flag resolution outcome. + * + * The base class also provides default implementations for + * `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` that can be + * used as-is or overridden by subclasses as needed. + */ +export abstract class BaseEvaluationStrategy { + public runMode: 'parallel' | 'sequential' = 'sequential'; + + constructor(protected statusEnum: Record) {} + + shouldEvaluateThisProvider( + strategyContext: BaseStrategyPerProviderContext, + _evalContext?: EvaluationContext, + ): boolean { + if ( + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL + ) { + return false; + } + return true; + } + + shouldEvaluateNextProvider( + _strategyContext?: BaseStrategyPerProviderContext, + _context?: EvaluationContext, + _result?: BaseProviderResolutionResult, + ): boolean { + return true; + } + + shouldTrackWithThisProvider( + strategyContext: BaseStrategyProviderContext, + _context?: EvaluationContext, + _trackingEventName?: string, + _trackingEventDetails?: TrackingEventDetails, + ): boolean { + if ( + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL + ) { + return false; + } + return true; + } + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult; + + protected hasError( + resolution: BaseProviderResolutionResult, + ): resolution is + | BaseProviderResolutionErrorResult + | (BaseProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; + }) { + return 'thrownError' in resolution || !!resolution.details.errorCode; + } + + protected hasErrorWithCode( + resolution: BaseProviderResolutionResult, + code: ErrorCode, + ): boolean { + return 'thrownError' in resolution + ? (resolution.thrownError as OpenFeatureError)?.code === code + : resolution.details.errorCode === code; + } + + protected collectProviderErrors( + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const errors: BaseFinalResult['errors'] = []; + for (const resolution of resolutions) { + if ('thrownError' in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), + }); + } + } + return { errors }; + } + + protected resolutionToFinalResult( + resolution: BaseProviderResolutionSuccessResult, + ) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts new file mode 100644 index 0000000000..8914713ca9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -0,0 +1,66 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseProviderResolutionSuccessResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import { GeneralError } from '../../../errors'; + +export class ComparisonStrategy extends BaseEvaluationStrategy { + override runMode = 'parallel' as const; + + constructor( + statusEnum: Record, + private fallbackProvider: TProvider, + private onMismatch?: (resolutions: BaseProviderResolutionResult[]) => void, + ) { + super(statusEnum); + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + let value: T | undefined; + let fallbackResolution: BaseProviderResolutionSuccessResult | undefined; + let finalResolution: BaseProviderResolutionSuccessResult | undefined; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== 'undefined' && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + + if (!fallbackResolution) { + throw new GeneralError('Fallback provider not found in resolution results'); + } + + if (!finalResolution) { + throw new GeneralError('Final resolution not found in resolution results'); + } + + if (mismatch) { + this.onMismatch?.(resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider, + }; + } + + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts new file mode 100644 index 0000000000..d9f66f6b9f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -0,0 +1,36 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import { ErrorCode } from '../../../evaluation'; + +export class FirstMatchStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + result: BaseProviderResolutionResult, + ): boolean { + if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts new file mode 100644 index 0000000000..8c3c8c008b --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -0,0 +1,32 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; + +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy< + TProviderStatus, + TProvider +> { + override shouldEvaluateNextProvider( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + result: BaseProviderResolutionResult, + ): boolean { + return this.hasError(result); + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts new file mode 100644 index 0000000000..d67a767a48 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './base-evaluation-strategy'; +export { FirstMatchStrategy as BaseFirstMatchStrategy } from './first-match-strategy'; +export { FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy } from './first-successful-strategy'; +export { ComparisonStrategy as BaseComparisonStrategy } from './comparison-strategy'; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts new file mode 100644 index 0000000000..96137f3e5e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts @@ -0,0 +1,6 @@ +export type ProviderEntryInput = { + provider: TProvider; + name?: string; +}; + +export type RegisteredProvider = Required>; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts b/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts new file mode 100644 index 0000000000..74ae8606f1 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts @@ -0,0 +1,137 @@ +import type { EvaluationContext } from '../evaluation'; +import type { AnyProviderEvent, ProviderEventEmitter } from '../events'; +import type { TrackingEventDetails } from '../tracking'; +import type { Metadata, Paradigm } from '../types'; + +// TODO: with TypeScript 5+, we can use computed string properties, +// so we can extract all of these into a shared set of string consts and use that in both enums +// for now we have duplicated them. + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +export enum ServerProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = 'NOT_READY', + + /** + * The provider is ready to resolve flags. + */ + READY = 'READY', + + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = 'ERROR', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = 'STALE', + + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = 'FATAL', +} + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +export enum ClientProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = 'NOT_READY', + + /** + * The provider is ready to resolve flags. + */ + READY = 'READY', + + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = 'ERROR', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = 'STALE', + + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = 'FATAL', + + /** + * The provider is reconciling its state with a context change. + */ + RECONCILING = 'RECONCILING', +} + +/** + * A type representing any possible ProviderStatus (server or client side). + * In most cases, you probably want to import `ProviderStatus` from the respective SDK. + */ +export { ClientProviderStatus as AllProviderStatus }; + +/** + * Static data about the provider. + */ +export interface ProviderMetadata extends Readonly { + readonly name: string; +} + +export interface CommonProvider { + readonly metadata: ProviderMetadata; + + /** + * Represents where the provider is intended to be run. If defined, + * the SDK will enforce that the defined paradigm at runtime. + */ + readonly runsOn?: Paradigm; + + // TODO: in the future we could make this a never to force provider to remove it. + /** + * @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field. + * Returns a representation of the current readiness of the provider. + * + * _Providers which do not implement this method are assumed to be ready immediately._ + */ + readonly status?: S; + + /** + * An event emitter for ProviderEvents. + * @see ProviderEvents + */ + events?: ProviderEventEmitter; + + /** + * A function used to shut down the provider. + * Called when this provider is replaced with a new one, or when the OpenFeature is shut down. + */ + onClose?(): Promise; + + /** + * A function used to setup the provider. + * Called by the SDK after the provider is set if the provider's status is NOT_READY. + * When the returned promise resolves, the SDK fires the ProviderEvents.Ready event. + * If the returned promise rejects, the SDK fires the ProviderEvents.Error event. + * Use this function to perform any context-dependent setup within the provider. + * @param context + */ + initialize?(context?: EvaluationContext): Promise; + + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName + * @param context + * @param trackingEventDetails + */ + track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts new file mode 100644 index 0000000000..4ec6b214b7 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts @@ -0,0 +1,88 @@ +/** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ +export const TelemetryAttribute = { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + KEY: 'feature_flag.key', + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + ERROR_CODE: 'error.type', + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + ERROR_MESSAGE: 'error.message', + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + VARIANT: 'feature_flag.result.variant', + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + VALUE: 'feature_flag.result.value', + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + CONTEXT_ID: 'feature_flag.context.id', + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + REASON: 'feature_flag.result.reason', + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + PROVIDER: 'feature_flag.provider.name', + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + FLAG_SET_ID: 'feature_flag.set.id', + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + VERSION: 'feature_flag.version', +} as const; diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts new file mode 100644 index 0000000000..ef67288071 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts @@ -0,0 +1,87 @@ +import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation'; +import type { HookContext } from '../hooks'; +import { TelemetryAttribute } from './attributes'; +import { TelemetryFlagMetadata } from './flag-metadata'; + +/** + * Attribute types for OpenTelemetry. + * @see https://github.com/open-telemetry/opentelemetry-js/blob/fbbce6e1c0de86e4c504b5788d876fae4d3bc254/api/src/common/Attributes.ts#L35 + */ +export declare type AttributeValue = string | number | boolean | string[] | number[] | boolean[]; + +type EvaluationEvent = { + /** + * The name of the feature flag evaluation event. + */ + name: string; + /** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @experimental The attributes are subject to change. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ + attributes: Record; +}; + +const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation'; + +/** + * Returns an OpenTelemetry compliant event for flag evaluation. + * @param {HookContext} hookContext Contextual information about the flag evaluation + * @param {EvaluationDetails} evaluationDetails The details of the flag evaluation + * @returns {EvaluationEvent} An evaluation event object containing the event name and attributes + */ +export function createEvaluationEvent( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, +): EvaluationEvent { + const attributes: EvaluationEvent['attributes'] = { + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(), + }; + + if (evaluationDetails.variant) { + attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant; + } + + if (evaluationDetails.value !== null) { + if (typeof evaluationDetails.value !== 'object') { + attributes[TelemetryAttribute.VALUE] = evaluationDetails.value; + } else { + try { + // Objects are not valid attribute values, so we convert them to a JSON string + attributes[TelemetryAttribute.VALUE] = JSON.stringify(evaluationDetails.value); + } catch { + // We ignore non serializable values + } + } + } + + const contextId = + evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID] ?? hookContext.context.targetingKey; + if (contextId) { + attributes[TelemetryAttribute.CONTEXT_ID] = contextId; + } + + const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID]; + if (setId) { + attributes[TelemetryAttribute.FLAG_SET_ID] = setId; + } + + const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION]; + if (version) { + attributes[TelemetryAttribute.VERSION] = version; + } + + if (evaluationDetails.reason === StandardResolutionReasons.ERROR) { + attributes[TelemetryAttribute.ERROR_CODE] = (evaluationDetails.errorCode ?? ErrorCode.GENERAL).toLowerCase(); + if (evaluationDetails.errorMessage) { + attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage; + } + } + + return { + name: FLAG_EVALUATION_EVENT_NAME, + attributes, + }; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts new file mode 100644 index 0000000000..db671ac2bc --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts @@ -0,0 +1,20 @@ +/** + * Well-known flag metadata attributes for telemetry events. + * @see https://openfeature.dev/specification/appendix-d#flag-metadata + */ +export const TelemetryFlagMetadata = { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + CONTEXT_ID: 'contextId', + /** + * A logical identifier for the flag set. + */ + FLAG_SET_ID: 'flagSetId', + /** + * A version string (format unspecified) for the flag or flag set. + */ + VERSION: 'version', +} as const; diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts new file mode 100644 index 0000000000..2f1561707b --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts @@ -0,0 +1,3 @@ +export * from './attributes'; +export * from './flag-metadata'; +export * from './evaluation-event'; diff --git a/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts b/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts new file mode 100644 index 0000000000..41426f0b30 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts @@ -0,0 +1 @@ +export * from './tracking-event'; diff --git a/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts b/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts new file mode 100644 index 0000000000..5818bd3e6d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts @@ -0,0 +1,13 @@ +import type { PrimitiveValue } from '../types'; + +export type TrackingEventValue = PrimitiveValue | Date | { [key: string]: TrackingEventValue } | TrackingEventValue[]; + +/** + * A container for arbitrary data that can relevant to tracking events. + */ +export type TrackingEventDetails = { + /** + * A numeric value associated with this event. + */ + value?: number; +} & Record; diff --git a/tests/fixtures/source-overlays/sources/core/src/type-guards.ts b/tests/fixtures/source-overlays/sources/core/src/type-guards.ts new file mode 100644 index 0000000000..5787ff9f0a --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/type-guards.ts @@ -0,0 +1,35 @@ +/** + * Checks whether the parameter is a string. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +/** + * Returns the parameter if it is a string, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {string|undefined} The parameter if it is a string, otherwise undefined + */ +export function stringOrUndefined(value: unknown): string | undefined { + return isString(value) ? value : undefined; +} + +/** + * Checks whether the parameter is an object. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is an object + */ +export function isObject(value: unknown): value is T { + return typeof value === 'object'; +} + +/** + * Returns the parameter if it is an object, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {object|undefined} The parameter if it is an object, otherwise undefined + */ +export function objectOrUndefined(value: unknown): T | undefined { + return isObject(value) ? value : undefined; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/types/index.ts b/tests/fixtures/source-overlays/sources/core/src/types/index.ts new file mode 100644 index 0000000000..6c96c078ae --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './metadata'; +export * from './paradigm'; +export * from './structure'; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts b/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts new file mode 100644 index 0000000000..1311758dca --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts @@ -0,0 +1 @@ +export type Metadata = Record; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts b/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts new file mode 100644 index 0000000000..91be161348 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts @@ -0,0 +1,4 @@ +/** + * Defines where the library is intended to be run. + */ +export type Paradigm = 'server' | 'client'; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/structure.ts b/tests/fixtures/source-overlays/sources/core/src/types/structure.ts new file mode 100644 index 0000000000..7edbdd9370 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/structure.ts @@ -0,0 +1,7 @@ +export type PrimitiveValue = null | boolean | string | number; +export type JsonObject = { [key: string]: JsonValue }; +export type JsonArray = JsonValue[]; +/** + * Represents a JSON node value. + */ +export type JsonValue = PrimitiveValue | JsonObject | JsonArray; diff --git a/tests/fixtures/source-overlays/sources/mitt/LICENSE b/tests/fixtures/source-overlays/sources/mitt/LICENSE new file mode 100644 index 0000000000..84c52fe2cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/mitt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/source-overlays/sources/mitt/src/index.ts b/tests/fixtures/source-overlays/sources/mitt/src/index.ts new file mode 100644 index 0000000000..17672aa176 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/mitt/src/index.ts @@ -0,0 +1,123 @@ +export type EventType = string | symbol; + +// An event handler can take an optional event argument +// and should not return a value +export type Handler = (event: T) => void; +export type WildcardHandler> = ( + type: keyof T, + event: T[keyof T] +) => void; + +// An array of all currently registered event handlers for a type +export type EventHandlerList = Array>; +export type WildCardEventHandlerList> = Array< + WildcardHandler +>; + +// A map of event types and their corresponding event handlers. +export type EventHandlerMap> = Map< + keyof Events | '*', + EventHandlerList | WildCardEventHandlerList +>; + +export interface Emitter> { + all: EventHandlerMap; + + on(type: Key, handler: Handler): void; + on(type: '*', handler: WildcardHandler): void; + + off( + type: Key, + handler?: Handler + ): void; + off(type: '*', handler: WildcardHandler): void; + + emit(type: Key, event: Events[Key]): void; + emit( + type: undefined extends Events[Key] ? Key : never + ): void; +} + +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} + */ +export default function mitt>( + all?: EventHandlerMap +): Emitter { + type GenericEventHandler = + | Handler + | WildcardHandler; + all = all || new Map(); + + return { + /** + * A Map of event names to registered handler functions. + */ + all, + + /** + * Register an event handler for the given type. + * @param {string|symbol} type Type of event to listen for, or `'*'` for all events + * @param {Function} handler Function to call in response to given event + * @memberOf mitt + */ + on(type: Key, handler: GenericEventHandler) { + const handlers: Array | undefined = all!.get(type); + if (handlers) { + handlers.push(handler); + } else { + all!.set(type, [handler] as EventHandlerList); + } + }, + + /** + * Remove an event handler for the given type. + * If `handler` is omitted, all handlers of the given type are removed. + * @param {string|symbol} type Type of event to unregister `handler` from (`'*'` to remove a wildcard handler) + * @param {Function} [handler] Handler function to remove + * @memberOf mitt + */ + off(type: Key, handler?: GenericEventHandler) { + const handlers: Array | undefined = all!.get(type); + if (handlers) { + if (handler) { + handlers.splice(handlers.indexOf(handler) >>> 0, 1); + } else { + all!.set(type, []); + } + } + }, + + /** + * Invoke all handlers for the given type. + * If present, `'*'` handlers are invoked after type-matched handlers. + * + * Note: Manually firing '*' handlers is not supported. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler + * @memberOf mitt + */ + emit(type: Key, evt?: Events[Key]) { + let handlers = all!.get(type); + if (handlers) { + (handlers as EventHandlerList) + .slice() + .map((handler) => { + handler(evt!); + }); + } + + handlers = all!.get('*'); + if (handlers) { + (handlers as WildCardEventHandlerList) + .slice() + .map((handler) => { + handler(type, evt!); + }); + } + } + }; +} diff --git a/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE b/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE new file mode 100644 index 0000000000..983fbe8aec --- /dev/null +++ b/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts b/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts new file mode 100644 index 0000000000..83d00ead4e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts @@ -0,0 +1,641 @@ +/** + * Tokenizer results. + */ +interface LexToken { + type: + | "OPEN" + | "CLOSE" + | "PATTERN" + | "NAME" + | "CHAR" + | "ESCAPED_CHAR" + | "MODIFIER" + | "END"; + index: number; + value: string; +} + +/** + * Tokenize input string. + */ +function lexer(str: string): LexToken[] { + const tokens: LexToken[] = []; + let i = 0; + + while (i < str.length) { + const char = str[i]; + + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + + if (char === ":") { + let name = ""; + let j = i + 1; + + while (j < str.length) { + const code = str.charCodeAt(j); + + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95 + ) { + name += str[j++]; + continue; + } + + break; + } + + if (!name) throw new TypeError(`Missing parameter name at ${i}`); + + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + + if (char === "(") { + let count = 1; + let pattern = ""; + let j = i + 1; + + if (str[j] === "?") { + throw new TypeError(`Pattern cannot start with "?" at ${j}`); + } + + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError(`Capturing groups are not allowed at ${j}`); + } + } + + pattern += str[j++]; + } + + if (count) throw new TypeError(`Unbalanced pattern at ${i}`); + if (!pattern) throw new TypeError(`Missing pattern at ${i}`); + + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + + tokens.push({ type: "END", index: i, value: "" }); + + return tokens; +} + +export interface ParseOptions { + /** + * Set the default delimiter for repeat parameters. (default: `'/'`) + */ + delimiter?: string; + /** + * List of characters to automatically consider prefixes when parsing. + */ + prefixes?: string; +} + +/** + * Parse a string for the raw tokens. + */ +export function parse(str: string, options: ParseOptions = {}): Token[] { + const tokens = lexer(str); + const { prefixes = "./", delimiter = "/#?" } = options; + const result: Token[] = []; + let key = 0; + let i = 0; + let path = ""; + + const tryConsume = (type: LexToken["type"]): string | undefined => { + if (i < tokens.length && tokens[i].type === type) return tokens[i++].value; + }; + + const mustConsume = (type: LexToken["type"]): string => { + const value = tryConsume(type); + if (value !== undefined) return value; + const { type: nextType, index } = tokens[i]; + throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); + }; + + const consumeText = (): string => { + let result = ""; + let value: string | undefined; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + + const isSafe = (value: string): boolean => { + for (const char of delimiter) if (value.indexOf(char) > -1) return true; + return false; + }; + + const safePattern = (prefix: string) => { + const prev = result[result.length - 1]; + const prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + + if (prev && !prevText) { + throw new TypeError( + `Must have text between two parameters, missing text after "${(prev as Key).name}"`, + ); + } + + if (!prevText || isSafe(prevText)) return `[^${escapeString(delimiter)}]+?`; + return `(?:(?!${escapeString(prevText)})[^${escapeString(delimiter)}])+?`; + }; + + while (i < tokens.length) { + const char = tryConsume("CHAR"); + const name = tryConsume("NAME"); + const pattern = tryConsume("PATTERN"); + + if (name || pattern) { + let prefix = char || ""; + + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + + if (path) { + result.push(path); + path = ""; + } + + result.push({ + name: name || key++, + prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + + const value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + + if (path) { + result.push(path); + path = ""; + } + + const open = tryConsume("OPEN"); + if (open) { + const prefix = consumeText(); + const name = tryConsume("NAME") || ""; + const pattern = tryConsume("PATTERN") || ""; + const suffix = consumeText(); + + mustConsume("CLOSE"); + + result.push({ + name: name || (pattern ? key++ : ""), + pattern: name && !pattern ? safePattern(prefix) : pattern, + prefix, + suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + + mustConsume("END"); + } + + return result; +} + +export interface TokensToFunctionOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * Function for encoding input strings for output. + */ + encode?: (value: string, token: Key) => string; + /** + * When `false` the function can produce an invalid (unmatched) path. (default: `true`) + */ + validate?: boolean; +} + +/** + * Compile a string to a template function for the path. + */ +export function compile

( + str: string, + options?: ParseOptions & TokensToFunctionOptions, +) { + return tokensToFunction

(parse(str, options), options); +} + +export type PathFunction

= (data?: P) => string; + +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction

( + tokens: Token[], + options: TokensToFunctionOptions = {}, +): PathFunction

{ + const reFlags = flags(options); + const { encode = (x: string) => x, validate = true } = options; + + // Compile all the tokens into regexps. + const matches = tokens.map((token) => { + if (typeof token === "object") { + return new RegExp(`^(?:${token.pattern})$`, reFlags); + } + }); + + return (data: Record | null | undefined) => { + let path = ""; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (typeof token === "string") { + path += token; + continue; + } + + const value = data ? data[token.name] : undefined; + const optional = token.modifier === "?" || token.modifier === "*"; + const repeat = token.modifier === "*" || token.modifier === "+"; + + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError( + `Expected "${token.name}" to not repeat, but got an array`, + ); + } + + if (value.length === 0) { + if (optional) continue; + + throw new TypeError(`Expected "${token.name}" to not be empty`); + } + + for (let j = 0; j < value.length; j++) { + const segment = encode(value[j], token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError( + `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`, + ); + } + + path += token.prefix + segment + token.suffix; + } + + continue; + } + + if (typeof value === "string" || typeof value === "number") { + const segment = encode(String(value), token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError( + `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`, + ); + } + + path += token.prefix + segment + token.suffix; + continue; + } + + if (optional) continue; + + const typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`); + } + + return path; + }; +} + +export interface RegexpToFunctionOptions { + /** + * Function for decoding strings for params. + */ + decode?: (value: string, token: Key) => string; +} + +/** + * A match result contains data about the path match. + */ +export interface MatchResult

{ + path: string; + index: number; + params: P; +} + +/** + * A match is either `false` (no match) or a match result. + */ +export type Match

= false | MatchResult

; + +/** + * The match function takes a string and returns whether it matched the path. + */ +export type MatchFunction

= ( + path: string, +) => Match

; + +/** + * Create path match function from `path-to-regexp` spec. + */ +export function match

( + str: Path, + options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions, +) { + const keys: Key[] = []; + const re = pathToRegexp(str, keys, options); + return regexpToFunction

(re, keys, options); +} + +/** + * Create a path match function from `path-to-regexp` output. + */ +export function regexpToFunction

( + re: RegExp, + keys: Key[], + options: RegexpToFunctionOptions = {}, +): MatchFunction

{ + const { decode = (x: string) => x } = options; + + return function (pathname: string) { + const m = re.exec(pathname); + if (!m) return false; + + const { 0: path, index } = m; + const params = Object.create(null); + + for (let i = 1; i < m.length; i++) { + if (m[i] === undefined) continue; + + const key = keys[i - 1]; + + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map((value) => { + return decode(value, key); + }); + } else { + params[key.name] = decode(m[i], key); + } + } + + return { path, index, params }; + }; +} + +/** + * Escape a regular expression string. + */ +function escapeString(str: string) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} + +/** + * Get the flags for a regexp from the options. + */ +function flags(options?: { sensitive?: boolean }) { + return options && options.sensitive ? "" : "i"; +} + +/** + * Metadata about a key. + */ +export interface Key { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +} + +/** + * A token is a string (nothing special) or key metadata (capture group). + */ +export type Token = string | Key; + +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp { + if (!keys) return path; + + const groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + + let index = 0; + let execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + + return path; +} + +/** + * Transform an array into a regexp. + */ +function arrayToRegexp( + paths: Array, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +): RegExp { + const parts = paths.map((path) => pathToRegexp(path, keys, options).source); + return new RegExp(`(?:${parts.join("|")})`, flags(options)); +} + +/** + * Create a path regexp from string input. + */ +function stringToRegexp( + path: string, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +) { + return tokensToRegexp(parse(path, options), keys, options); +} + +export interface TokensToRegexpOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) + */ + strict?: boolean; + /** + * When `true` the regexp will match to the end of the string. (default: `true`) + */ + end?: boolean; + /** + * When `true` the regexp will match from the beginning of the string. (default: `true`) + */ + start?: boolean; + /** + * Sets the final character for non-ending optimistic matches. (default: `/`) + */ + delimiter?: string; + /** + * List of characters that can also be "end" characters. + */ + endsWith?: string; + /** + * Encode path tokens for use in the `RegExp`. + */ + encode?: (value: string) => string; +} + +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export function tokensToRegexp( + tokens: Token[], + keys?: Key[], + options: TokensToRegexpOptions = {}, +) { + const { + strict = false, + start = true, + end = true, + encode = (x: string) => x, + delimiter = "/#?", + endsWith = "", + } = options; + const endsWithRe = `[${escapeString(endsWith)}]|$`; + const delimiterRe = `[${escapeString(delimiter)}]`; + let route = start ? "^" : ""; + + // Iterate over the tokens and create our regexp string. + for (const token of tokens) { + if (typeof token === "string") { + route += escapeString(encode(token)); + } else { + const prefix = escapeString(encode(token.prefix)); + const suffix = escapeString(encode(token.suffix)); + + if (token.pattern) { + if (keys) keys.push(token); + + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + const mod = token.modifier === "*" ? "?" : ""; + route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; + } else { + route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; + } + } else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError( + `Can not repeat "${token.name}" without a prefix and suffix`, + ); + } + + route += `(${token.pattern})${token.modifier}`; + } + } else { + route += `(?:${prefix}${suffix})${token.modifier}`; + } + } + } + + if (end) { + if (!strict) route += `${delimiterRe}?`; + + route += !options.endsWith ? "$" : `(?=${endsWithRe})`; + } else { + const endToken = tokens[tokens.length - 1]; + const isEndDelimited = + typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + + if (!strict) { + route += `(?:${delimiterRe}(?=${endsWithRe}))?`; + } + + if (!isEndDelimited) { + route += `(?=${delimiterRe}|${endsWithRe})`; + } + } + + return new RegExp(route, flags(options)); +} + +/** + * Supported `path-to-regexp` input types. + */ +export type Path = string | RegExp | Array; + +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export function pathToRegexp( + path: Path, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +) { + if (path instanceof RegExp) return regexpToRegexp(path, keys); + if (Array.isArray(path)) return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/LICENSE b/tests/fixtures/source-overlays/sources/server-sdk/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts new file mode 100644 index 0000000000..0446246bc9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts @@ -0,0 +1,25 @@ +import type { + ClientMetadata, + EvaluationLifeCycle, + Eventing, + ManageContext, + ManageLogger, +} from '@openfeature/core'; +import type { Features } from '../evaluation'; +import type { ProviderStatus } from '../provider'; +import type { ProviderEvents } from '../events'; +import type { Tracking } from '../tracking'; + +export interface Client + extends EvaluationLifeCycle, + Features, + ManageContext, + ManageLogger, + Tracking, + Eventing { + readonly metadata: ClientMetadata; + /** + * Returns the status of the associated provider. + */ + readonly providerStatus: ProviderStatus; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts new file mode 100644 index 0000000000..4f1cce44fa --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts new file mode 100644 index 0000000000..4f440e9401 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts @@ -0,0 +1,436 @@ +import type { + ClientMetadata, + EvaluationContext, + EvaluationDetails, + EventHandler, + FlagValue, + FlagValueType, + HookContext, + JsonValue, + Logger, + TrackingEventDetails, + OpenFeatureError, + FlagMetadata, + ResolutionDetails, + EventOptions, +} from '@openfeature/core'; +import { + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, + SafeLogger, + StandardResolutionReasons, + instantiateErrorByErrorCode, + statusMatchesEvent, +} from '@openfeature/core'; +import type { FlagEvaluationOptions } from '../../evaluation'; +import type { ProviderEvents } from '../../events'; +import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter'; +import type { Hook } from '../../hooks'; +import type { Provider } from '../../provider'; +import { ProviderStatus } from '../../provider'; +import type { Client } from './../client'; + +type OpenFeatureClientOptions = { + /** + * @deprecated Use `domain` instead. + */ + name?: string; + domain?: string; + version?: string; +}; + +/** + * This implementation of the {@link Client} is meant to only be instantiated by the SDK. + * It should not be used outside the SDK and so should not be exported. + * @internal + */ +export class OpenFeatureClient implements Client { + private _context: EvaluationContext; + private _hooks: Hook[] = []; + private _clientLogger?: Logger; + + constructor( + // we always want the client to use the current provider, + // so pass a function to always access the currently registered one. + private readonly providerAccessor: () => Provider, + private readonly providerStatusAccessor: () => ProviderStatus, + private readonly emitterAccessor: () => InternalEventEmitter, + private readonly apiContextAccessor: () => EvaluationContext, + private readonly apiHooksAccessor: () => Hook[], + private readonly transactionContextAccessor: () => EvaluationContext, + private readonly globalLogger: () => Logger, + private readonly options: OpenFeatureClientOptions, + context: EvaluationContext = {}, + ) { + this._context = context; + } + + get metadata(): ClientMetadata { + return { + // Use domain if name is not provided + name: this.options.domain ?? this.options.name, + domain: this.options.domain ?? this.options.name, + version: this.options.version, + providerMetadata: this.providerAccessor().metadata, + }; + } + + get providerStatus(): ProviderStatus { + return this.providerStatusAccessor(); + } + + addHandler(eventType: ProviderEvents, handler: EventHandler, options?: EventOptions): void { + this.emitterAccessor().addHandler(eventType, handler); + const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus); + + if (shouldRunNow) { + // run immediately, we're in the matching state + try { + handler({ + clientName: this.metadata.name, + domain: this.metadata.domain, + providerName: this._provider.metadata.name, + }); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + } + + if (options?.signal && typeof options.signal.addEventListener === 'function') { + options.signal.addEventListener('abort', () => { + this.removeHandler(eventType, handler); + }); + } + } + + removeHandler(eventType: ProviderEvents, handler: EventHandler) { + this.emitterAccessor().removeHandler(eventType, handler); + } + + getHandlers(eventType: ProviderEvents) { + return this.emitterAccessor().getHandlers(eventType); + } + + setLogger(logger: Logger): OpenFeatureClient { + this._clientLogger = new SafeLogger(logger); + return this; + } + + setContext(context: EvaluationContext): OpenFeatureClient { + this._context = context; + return this; + } + + getContext(): EvaluationContext { + return this._context; + } + + addHooks(...hooks: Hook[]): OpenFeatureClient { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + + getHooks(): Hook[] { + return this._hooks; + } + + clearHooks(): OpenFeatureClient { + this._hooks = []; + return this; + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getBooleanDetails(flagKey, defaultValue, context, options)).value; + } + + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + this._provider.resolveBooleanEvaluation, + defaultValue, + 'boolean', + context, + options, + ); + } + + async getStringValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getStringDetails(flagKey, defaultValue, context, options)).value; + } + + getStringDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + // this isolates providers from our restricted string generic argument. + this._provider.resolveStringEvaluation as () => Promise>, + defaultValue, + 'string', + context, + options, + ); + } + + async getNumberValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getNumberDetails(flagKey, defaultValue, context, options)).value; + } + + getNumberDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + // this isolates providers from our restricted number generic argument. + this._provider.resolveNumberEvaluation as () => Promise>, + defaultValue, + 'number', + context, + options, + ); + } + + async getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getObjectDetails(flagKey, defaultValue, context, options)).value; + } + + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options); + } + + track(occurrenceKey: string, context: EvaluationContext = {}, occurrenceDetails: TrackingEventDetails = {}): void { + try { + this.shortCircuitIfNotReady(); + + if (typeof this._provider.track === 'function') { + // freeze the merged context + const frozenContext = Object.freeze(this.mergeContexts(context)); + return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails); + } else { + this._logger.debug('Provider does not support the track function; will no-op.'); + } + } catch (err) { + this._logger.debug('Error recording tracking event.', err); + } + } + + private async evaluate( + flagKey: string, + resolver: ( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ) => Promise>, + defaultValue: T, + flagType: FlagValueType, + invocationContext: EvaluationContext = {}, + options: FlagEvaluationOptions = {}, + ): Promise> { + // merge global, client, and evaluation context + + const allHooks = [ + ...this.apiHooksAccessor(), + ...this.getHooks(), + ...(options.hooks || []), + ...(this._provider.hooks || []), + ]; + const allHooksReversed = [...allHooks].reverse(); + + const mergedContext = this.mergeContexts(invocationContext); + + // this reference cannot change during the course of evaluation + // it may be used as a key in WeakMaps + const hookContext: Readonly = { + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context: mergedContext, + logger: this._logger, + }; + + let evaluationDetails: EvaluationDetails; + + try { + const frozenContext = await this.beforeHooks(allHooks, hookContext, options); + + this.shortCircuitIfNotReady(); + + // run the referenced resolver, binding the provider. + const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); + + const resolutionDetails = { + ...resolution, + flagMetadata: Object.freeze(resolution.flagMetadata ?? {}), + flagKey, + }; + + if (resolutionDetails.errorCode) { + const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); + await this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); + } else { + await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + evaluationDetails = resolutionDetails; + } + } catch (err: unknown) { + await this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); + } + + await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + return evaluationDetails; + } + + private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) { + for (const hook of hooks) { + // freeze the hookContext + Object.freeze(hookContext); + + // use Object.assign to avoid modification of frozen hookContext + Object.assign(hookContext.context, { + ...hookContext.context, + ...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))), + }); + } + + // after before hooks, freeze the EvaluationContext. + return Object.freeze(hookContext.context); + } + + private async afterHooks( + hooks: Hook[], + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + options: FlagEvaluationOptions, + ) { + // run "after" hooks sequentially + for (const hook of hooks) { + await hook?.after?.(hookContext, evaluationDetails, options.hookHints); + } + } + + private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) { + // run "error" hooks sequentially + for (const hook of hooks) { + try { + await hook?.error?.(hookContext, err, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'error' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error((err as Error)?.stack); + } + } + } + + private async finallyHooks( + hooks: Hook[], + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + options: FlagEvaluationOptions, + ) { + // run "finally" hooks sequentially + for (const hook of hooks) { + try { + await hook?.finally?.(hookContext, evaluationDetails, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error((err as Error)?.stack); + } + } + } + + private get _provider(): Provider { + return this.providerAccessor(); + } + + private get _providerStatus(): ProviderStatus { + return this.providerStatusAccessor(); + } + + private get _logger() { + return this._clientLogger || this.globalLogger(); + } + + private mergeContexts(invocationContext: EvaluationContext) { + // merge global and client contexts + return { + ...this.apiContextAccessor(), + ...this.transactionContextAccessor(), + ...this._context, + ...invocationContext, + }; + } + + private shortCircuitIfNotReady() { + // short circuit evaluation entirely if provider is in a bad state + if (this.providerStatus === ProviderStatus.NOT_READY) { + throw new ProviderNotReadyError('provider has not yet initialized'); + } else if (this.providerStatus === ProviderStatus.FATAL) { + throw new ProviderFatalError('provider is in an irrecoverable error state'); + } + } + + private getErrorEvaluationDetails( + flagKey: string, + defaultValue: T, + err: unknown, + flagMetadata: FlagMetadata = {}, + ): EvaluationDetails { + const errorMessage: string = (err as Error)?.message; + const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL; + + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey, + }; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts new file mode 100644 index 0000000000..b43c4f1268 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts @@ -0,0 +1,177 @@ +import type { EvaluationContext, EvaluationDetails, HookHints, JsonValue } from '@openfeature/core'; +import type { Hook } from '../hooks'; + +export interface FlagEvaluationOptions { + hooks?: Hook[]; + hookHints?: HookHints; +} + +export interface Features { + /** + * Performs a flag evaluation that returns a boolean. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns a string. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getStringValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getStringDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns a number. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getNumberValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getNumberDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns an object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getObjectValue( + flagKey: string, + defaultValue: JsonValue, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getObjectDetails( + flagKey: string, + defaultValue: JsonValue, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts new file mode 100644 index 0000000000..79c222c37d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts @@ -0,0 +1 @@ +export * from './evaluation'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts new file mode 100644 index 0000000000..4d7205a517 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts @@ -0,0 +1,3 @@ +import { ServerProviderEvents } from '@openfeature/core'; + +export { ServerProviderEvents as ProviderEvents }; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts new file mode 100644 index 0000000000..6093005ef0 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts @@ -0,0 +1,2 @@ +export * from './open-feature-event-emitter'; +export * from './events'; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts new file mode 100644 index 0000000000..0a5c18c68d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts @@ -0,0 +1,9 @@ +import { GenericEventEmitter } from '@openfeature/core'; +import type { ProviderEvents } from '../events'; + +/** + * The InternalEventEmitter is not exported publicly and should only be used within the SDK. It extends the + * OpenFeatureEventEmitter to include additional properties that can be included + * in the event details. + */ +export abstract class InternalEventEmitter extends GenericEventEmitter {}; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts new file mode 100644 index 0000000000..eea9172e59 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts @@ -0,0 +1,21 @@ +import { GenericEventEmitter } from '@openfeature/core'; +import { EventEmitter } from 'node:events'; +import type { ProviderEvents } from './events'; + +/** + * The OpenFeatureEventEmitter can be used by provider developers to emit + * events at various parts of the provider lifecycle. + * + * NOTE: Ready and error events are automatically emitted by the SDK based on + * the result of the initialize method. + */ +export class OpenFeatureEventEmitter extends GenericEventEmitter { + protected readonly eventEmitter = new EventEmitter({ captureRejections: true }); + + constructor() { + super(); + this.eventEmitter.on('error', (err) => { + this._logger?.error('Error running event handler:', err); + }); + } +}; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts new file mode 100644 index 0000000000..c7fb077314 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts @@ -0,0 +1,7 @@ +import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core'; + +export type Hook = BaseHook< + FlagValue, + Promise | EvaluationContext | void, + Promise | void +>; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts new file mode 100644 index 0000000000..1146a6d5c3 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './hook'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts new file mode 100644 index 0000000000..249135cbf8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts @@ -0,0 +1,9 @@ +export * from './client'; +export * from './provider'; +export * from './evaluation'; +export * from './open-feature'; +export * from './transaction-context'; +export * from './events'; +export * from './hooks'; +export * from './tracking'; +export * from '@openfeature/core'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts new file mode 100644 index 0000000000..3e818af05f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts @@ -0,0 +1,277 @@ +import type { + EvaluationContext, + ManageContext, + ServerProviderStatus} from '@openfeature/core'; +import { + OpenFeatureCommonAPI, + ProviderWrapper, + objectOrUndefined, + stringOrUndefined, +} from '@openfeature/core'; +import type { Client } from './client'; +import { OpenFeatureClient } from './client/internal/open-feature-client'; +import { OpenFeatureEventEmitter } from './events'; +import type { Hook } from './hooks'; +import type { Provider} from './provider'; +import { NOOP_PROVIDER, ProviderStatus } from './provider'; +import type { + ManageTransactionContextPropagator, + TransactionContext, + TransactionContextPropagator} from './transaction-context'; +import { + NOOP_TRANSACTION_CONTEXT_PROPAGATOR +} from './transaction-context'; + +// use a symbol as a key for the global singleton +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/js-sdk/api'); + +type OpenFeatureGlobal = { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI; +}; +const _globalThis = globalThis as OpenFeatureGlobal; + +export class OpenFeatureAPI + extends OpenFeatureCommonAPI + implements + ManageContext, + ManageTransactionContextPropagator> +{ + protected _statusEnumType: typeof ProviderStatus = ProviderStatus; + protected _apiEmitter = new OpenFeatureEventEmitter(); + protected _defaultProvider: ProviderWrapper = new ProviderWrapper( + NOOP_PROVIDER, + ProviderStatus.NOT_READY, + this._statusEnumType, + ); + protected _domainScopedProviders: Map> = new Map(); + protected _createEventEmitter = () => new OpenFeatureEventEmitter(); + + private _transactionContextPropagator: TransactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + + private constructor() { + super('server'); + } + + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance(): OpenFeatureAPI { + const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + if (globalApi) { + return globalApi; + } + + const instance = new OpenFeatureAPI(); + _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + return instance; + } + + private getProviderStatus(domain?: string): ProviderStatus { + if (!domain) { + return this._defaultProvider.status; + } + + return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status; + } + + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(domainOrProvider); + + await this.setAwaitableProvider(domain, provider); + } + + /** + * Sets the default provider for flag evaluations. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(provider: Provider): this; + /** + * Sets the provider for flag evaluations of providers with the given name. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(domain: string, provider: Provider): this; + setProvider(clientOrProvider?: string | Provider, providerOrUndefined?: Provider): this { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(clientOrProvider); + + const maybePromise = this.setAwaitableProvider(domain, provider); + + // The setProvider method doesn't return a promise so we need to catch and + // log any errors that occur during provider initialization to avoid having + // an unhandled promise rejection. + Promise.resolve(maybePromise).catch((err) => { + this._logger.error('Error during provider initialization:', err); + }); + + return this; + } + + /** + * Get the default provider. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @returns {Provider} Default Provider + */ + getProvider(): Provider; + /** + * Get the provider bound to the specified domain. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {Provider} Domain-scoped provider + */ + getProvider(domain?: string): Provider; + getProvider(domain?: string): Provider { + return this.getProviderForClient(domain); + } + + setContext(context: EvaluationContext): this { + this._context = context; + return this; + } + + getContext(): EvaluationContext { + return this._context; + } + + /** + * A factory function for creating new domainless OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {string} version The version of the client (only used for metadata) + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, version: string, context?: EvaluationContext): Client; + getClient( + domainOrContext?: string | EvaluationContext, + versionOrContext?: string | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Client { + const domain = stringOrUndefined(domainOrContext); + const version = stringOrUndefined(versionOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(versionOrContext) ?? + objectOrUndefined(contextOrUndefined); + + return new OpenFeatureClient( + () => this.getProviderForClient(domain), + () => this.getProviderStatus(domain), + () => this.buildAndCacheEventEmitterForClient(domain), + () => this.getContext(), + () => this.getHooks(), + () => this.getTransactionContext(), + () => this._logger, + { domain, version }, + context, + ); + } + + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders(): Promise { + return super.clearProvidersAndSetDefault(NOOP_PROVIDER); + } + + setTransactionContextPropagator( + transactionContextPropagator: TransactionContextPropagator, + ): OpenFeatureCommonAPI { + const baseMessage = 'Invalid TransactionContextPropagator, will not be set: '; + if (typeof transactionContextPropagator?.getTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); + } else if (typeof transactionContextPropagator?.setTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); + } else { + this._transactionContextPropagator = transactionContextPropagator; + } + return this; + } + + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); + } + + getTransactionContext(): TransactionContext { + try { + return this._transactionContextPropagator.getTransactionContext(); + } catch (err: unknown) { + const error = err as Error | undefined; + this._logger.error(`Error getting transaction context: ${error?.message}, returning empty context.`); + this._logger.error(error?.stack); + return {}; + } + } +} + +/** + * A singleton instance of the OpenFeature API. + * @returns {OpenFeatureAPI} OpenFeature API + */ +export const OpenFeature = OpenFeatureAPI.getInstance(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts new file mode 100644 index 0000000000..05f507d444 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts @@ -0,0 +1,36 @@ +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ +import type { EvaluationContext, JsonValue } from '@openfeature/core'; + +type Variants = Record; + +/** + * A Feature Flag definition, containing it's specification + */ +export type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; + +export type FlagConfiguration = Record; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts new file mode 100644 index 0000000000..345c4aad78 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts @@ -0,0 +1,139 @@ +import type { + EvaluationContext, + FlagValueType, + JsonValue, + Logger, + ResolutionDetails} from '@openfeature/core'; +import { + FlagNotFoundError, + GeneralError, + OpenFeatureError, + StandardResolutionReasons, + TypeMismatchError +} from '@openfeature/core'; +import type { Provider } from '../provider'; +import type { Flag, FlagConfiguration } from './flag-configuration'; +import { VariantFoundError } from './variant-not-found-error'; +import { OpenFeatureEventEmitter, ProviderEvents } from '../..'; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +export class InMemoryProvider implements Provider { + public readonly events = new OpenFeatureEventEmitter(); + public readonly runsOn = 'server'; + readonly metadata = { + name: 'in-memory', + } as const; + private _flagConfiguration: FlagConfiguration; + + constructor(flagConfiguration: FlagConfiguration = {}) { + this._flagConfiguration = { ...flagConfiguration }; + } + + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration: FlagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration) + .filter(([key, value]) => this._flagConfiguration[key] !== value) + .map(([key]) => key); + + this._flagConfiguration = { ...flagConfiguration }; + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + private async resolveFlagWithReason( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): Promise> { + try { + const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger); + + if (typeof resolutionResult?.value != typeof defaultValue) { + throw new TypeMismatchError(); + } + + return resolutionResult; + } catch (error: unknown) { + if (!(error instanceof OpenFeatureError)) { + throw new GeneralError((error as Error)?.message || 'unknown error'); + } + throw error; + } + } + + private lookupFlagValue( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger?.debug(message); + throw new FlagNotFoundError(message); + } + const flagSpec: Flag = this._flagConfiguration[flagKey]; + + if (flagSpec.disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + + const isContextEval = ctx && flagSpec?.contextEvaluator; + const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant; + + const value = variant && flagSpec?.variants[variant]; + + if (value === undefined) { + const message = `no value associated with variant ${variant}`; + logger?.error(message); + throw new VariantFoundError(message); + } + + return { + value: value as T, + ...(variant && { variant }), + reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC, + }; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts new file mode 100644 index 0000000000..1bf0d53764 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts @@ -0,0 +1 @@ +export * from './in-memory-provider'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts new file mode 100644 index 0000000000..3553a7c090 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts @@ -0,0 +1,15 @@ +import { ErrorCode, OpenFeatureError } from '@openfeature/core'; + +/** + * A custom error for the in-memory provider. + * Indicates the resolved or default variant doesn't exist. + */ +export class VariantFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, VariantFoundError.prototype); + this.name = 'VariantFoundError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts new file mode 100644 index 0000000000..33e12fea3a --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts @@ -0,0 +1,3 @@ +export * from './provider'; +export * from './no-op-provider'; +export * from './in-memory-provider'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts new file mode 100644 index 0000000000..b7081c08cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts @@ -0,0 +1,38 @@ +import type { JsonValue, ResolutionDetails } from '@openfeature/core'; +import type { Provider } from './provider'; + +const REASON_NO_OP = 'No-op'; + +/** + * The No-op provider is set by default, and simply always returns the default value. + */ +class NoopFeatureProvider implements Provider { + readonly metadata = { + name: 'No-op Provider', + } as const; + + resolveBooleanEvaluation(_: string, defaultValue: boolean): Promise> { + return this.noOp(defaultValue); + } + + resolveStringEvaluation(_: string, defaultValue: string): Promise> { + return this.noOp(defaultValue); + } + + resolveNumberEvaluation(_: string, defaultValue: number): Promise> { + return this.noOp(defaultValue); + } + + resolveObjectEvaluation(_: string, defaultValue: T): Promise> { + return this.noOp(defaultValue); + } + + private noOp(defaultValue: T) { + return Promise.resolve({ + value: defaultValue, + reason: REASON_NO_OP, + }); + } +} + +export const NOOP_PROVIDER = new NoopFeatureProvider(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts new file mode 100644 index 0000000000..4c97f17758 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts @@ -0,0 +1,68 @@ +import type { + CommonProvider, + EvaluationContext, + JsonValue, + Logger, + ResolutionDetails} from '@openfeature/core'; +import { + ServerProviderStatus, +} from '@openfeature/core'; +import type { Hook } from '../hooks'; + +export { ServerProviderStatus as ProviderStatus }; + +/** + * Interface that providers must implement to resolve flag values for their particular + * backend or vendor. + * + * Implementation for resolving all the required flag types must be defined. + */ +export interface Provider extends CommonProvider { + /** + * A provider hook exposes a mechanism for provider authors to register hooks + * to tap into various stages of the flag evaluation lifecycle. These hooks can + * be used to perform side effects and mutate the context for purposes of the + * provider. Provider hooks are not configured or controlled by the application author. + */ + readonly hooks?: Hook[]; + + /** + * Resolve a boolean flag and its evaluation details. + */ + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve a string flag and its evaluation details. + */ + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve a numeric flag and its evaluation details. + */ + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve and parse an object flag and its evaluation details. + */ + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ): Promise>; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts new file mode 100644 index 0000000000..5794a1d40f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts @@ -0,0 +1 @@ +export * from './tracking'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts new file mode 100644 index 0000000000..b7ef17e9c7 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts @@ -0,0 +1,12 @@ +import type { EvaluationContext, TrackingEventDetails } from '@openfeature/core'; + +export interface Tracking { + + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName an identifier for the event + * @param context the evaluation context + * @param trackingEventDetails the details of the tracking event + */ + track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts new file mode 100644 index 0000000000..21cce7d664 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts @@ -0,0 +1,19 @@ +import type { EvaluationContext } from '@openfeature/core'; +import type { TransactionContext, TransactionContextPropagator } from './transaction-context'; +import { AsyncLocalStorage } from 'async_hooks'; + +export class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator { + private asyncLocalStorage = new AsyncLocalStorage(); + + getTransactionContext(): EvaluationContext { + return this.asyncLocalStorage.getStore() ?? {}; + } + + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + this.asyncLocalStorage.run(transactionContext, callback, ...args); + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts new file mode 100644 index 0000000000..34e976d33e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts @@ -0,0 +1,3 @@ +export * from './transaction-context'; +export * from './no-op-transaction-context-propagator'; +export * from './async-local-storage-transaction-context-propagator'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts new file mode 100644 index 0000000000..ee0a176f5c --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts @@ -0,0 +1,18 @@ +import type { EvaluationContext } from '@openfeature/core'; +import type { TransactionContext, TransactionContextPropagator } from './transaction-context'; + +class NoopTransactionContextPropagator implements TransactionContextPropagator { + getTransactionContext(): EvaluationContext { + return {}; + } + + setTransactionContext( + _: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + callback(...args); + } +} + +export const NOOP_TRANSACTION_CONTEXT_PROPAGATOR = new NoopTransactionContextPropagator(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts new file mode 100644 index 0000000000..f305a107eb --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts @@ -0,0 +1,63 @@ +import type { EvaluationContext } from '@openfeature/core'; + +/** + * Transaction context is a mechanism for adding transaction specific context that + * is merged with evaluation context prior to flag evaluation. Examples of potential + * transaction specific context include: a user id, user agent, or request path. + */ +export type TransactionContext = EvaluationContext; + +export interface ManageTransactionContextPropagator extends TransactionContextPropagator { + /** + * Sets a transaction context propagator on this receiver. The transaction context + * propagator is responsible for persisting context for the duration of a single + * transaction. + * @template T The type of the receiver + * @param {TransactionContextPropagator} transactionContextPropagator The context propagator to be used + * @returns {T} The receiver (this object) + */ + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): T; +} + +export interface TransactionContextPropagator { + /** + * Returns the currently defined transaction context using the registered transaction + * context propagator. + * @returns {TransactionContext} The current transaction context + */ + getTransactionContext(): TransactionContext; + + /** + * Sets the transaction context using the registered transaction context propagator. + * Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling + * {@link this#getTransactionContext}. + * + * The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available + * to {@link callback} via {@link this#getTransactionContext}. + * + * The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}. + * + * Example: + * + * ```js + * app.use((req: Request, res: Response, next: NextFunction) => { + * const ip = res.headers.get("X-Forwarded-For") + * OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => { + * // The transaction context is used in any flag evaluation throughout the whole call chain of next + * next(); + * }); + * }) + * + * ``` + * @template TArgs The optional args passed to the callback function + * @template R The return value of the callback + * @param {TransactionContext} transactionContext The transaction specific context + * @param {(...args: unknown[]) => R} callback Callback function to run + * @param {...unknown[]} args Optional arguments that are passed to the callback function + */ + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void; +} diff --git a/tests/harness/source-overlays.test.ts b/tests/harness/source-overlays.test.ts new file mode 100644 index 0000000000..459a6cad51 --- /dev/null +++ b/tests/harness/source-overlays.test.ts @@ -0,0 +1,306 @@ +/* --sources end to end: a pnpm-installed project, upstream TypeScript for + * three of its packages, and a schema-1 manifest that this test writes the + * way scriptc-pm would (tests/fixtures/source-overlays holds the installed + * tree and the upstream sources at the published commits). Every build + * goes through the CLI so the flag, manifest discovery, fallback notes and + * the build cache are covered as users see them. */ +import { execFile } from "node:child_process"; +import { + appendFileSync, + cpSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixture = join(repoRoot, "tests/fixtures/source-overlays"); +const cli = join(repoRoot, "packages/cli/dist/main.js"); + +interface OverlaySpec { + name: string; + version: string; + /** pnpm's instance directory under node_modules/.pnpm. */ + instance: string; + /** Directory under fixtures/source-overlays/sources. */ + source: string; + repo: string; + commit: string; + subdir?: string; +} + +const OVERLAYS: OverlaySpec[] = [ + { + name: "path-to-regexp", + version: "6.3.0", + instance: "path-to-regexp@6.3.0", + source: "path-to-regexp", + repo: "github.com/pillarjs/path-to-regexp", + commit: "75a92c3d7c42159f459ab42f346899152906ea8c", + }, + { + name: "@openfeature/server-sdk", + version: "1.18.0", + instance: "@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2", + source: "server-sdk", + repo: "github.com/open-feature/js-sdk", + commit: "aff60f6776043bee3360053bbc76af5d95e907ae", + subdir: "packages/server", + }, + { + name: "@openfeature/core", + version: "1.9.2", + instance: "@openfeature+core@1.9.2", + source: "core", + repo: "github.com/open-feature/js-sdk", + commit: "2064d75ae097ff66f7d5e8073eb0abf711a5a3b8", + subdir: "packages/shared", + }, + { + name: "mitt", + version: "3.0.1", + instance: "mitt@3.0.1", + source: "mitt", + repo: "github.com/developit/mitt", + commit: "b240473b5707857ba2c6a8e6d707c28d1e39da49", + }, +]; + +/** Copies the installed project to a fresh directory outside every + * node_modules tree and materializes `.scriptc-pm/` like scriptc-pm sync: + * one overlay per instance (upstream source, a synthesized package.json, + * and a node_modules link to the instance's own pnpm dependency dir) and + * the manifest. */ +function materialize(root: string): string { + const project = join(root, "project"); + cpSync(join(fixture, "project"), project, { recursive: true, verbatimSymlinks: true }); + const instances = OVERLAYS.map((o) => { + const pkg = join(project, ".scriptc-pm/overlay", o.instance, "pkg"); + cpSync(join(fixture, "sources", o.source), pkg, { recursive: true }); + const entry = "./src/index.ts"; + const exports = { ".": { types: entry, import: entry, require: entry, default: entry } }; + writeFileSync( + join(pkg, "package.json"), + `${JSON.stringify({ name: o.name, version: o.version, type: "module", exports }, null, 2)}\n`, + ); + const pnpmDeps = join(project, "node_modules/.pnpm", o.instance, "node_modules"); + symlinkSync(pnpmDeps, join(pkg, "node_modules")); + return { + installedDir: join(pnpmDeps, o.name), + name: o.name, + version: o.version, + overlayDir: pkg, + exports, + publishedFormat: "dual", + source: { + repo: o.repo, + commit: o.commit, + ...(o.subdir !== undefined ? { subdir: o.subdir } : {}), + strategy: "fixture", + }, + verification: { identity: "fixture", content: "unverified", surface: "unverified" }, + }; + }); + writeFileSync( + join(project, ".scriptc-pm/manifest.json"), + `${JSON.stringify({ schema: 1, generator: "source-overlays.test", lockDigest: "sha256-fixture", instances, fallbacks: [] }, null, 2)}\n`, + ); + return project; +} + +interface Outcome { + code: number; + stdout: string; + stderr: string; +} + +async function run( + file: string, + args: string[] = [], + cwd?: string, + env?: NodeJS.ProcessEnv, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(file, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + ...(cwd !== undefined ? { cwd } : {}), + ...(env !== undefined ? { env } : {}), + }); + return { code: 0, stdout, stderr }; + } catch (e) { + const err = e as { code?: number; stdout?: string; stderr?: string }; + return { code: err.code ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +function scriptc(args: string[], cwd: string, cacheDir: string): Promise { + return run(process.execPath, [cli, ...args], cwd, { + ...process.env, + SCRIPTC_CACHE_DIR: cacheDir, + SCRIPTC_TIMING: "1", + }); +} + +const cacheHit = (stderr: string): boolean => stderr.includes('"phase":"executable-cache-hit"'); + +let root: string; +let project: string; +let cacheDir: string; + +beforeAll(() => { + root = mkdtempSync(join(realpathSync(tmpdir()), "scriptc-sources-")); + project = materialize(root); + // scriptc creates the cache root itself (private, mode 0700). + cacheDir = join(root, "cache"); +}); + +afterAll(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }); +}); + +async function node(program: string, cwd = project): Promise { + return run(process.execPath, [program], cwd); +} + +describe("--sources", () => { + test("path-to-regexp's parser compiles from source and matches Node; its published build does not", async () => { + const expected = await node("routes.ts"); + expect(expected.code).toBe(0); + + // Published JS: the ES5 dist's String#concat calls compile to runtime + // fences, so the binary diverges from Node. + const published = await scriptc(["build", "routes.ts", "-o", "routes-pub"], project, cacheDir); + expect(published.code).toBe(0); + expect((await run(join(project, "routes-pub"))).stderr).toContain("SC2020"); + + const first = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-src"], + project, + cacheDir, + ); + expect(first.stderr).not.toContain("source overlay dropped"); + expect(first.code).toBe(0); + expect(cacheHit(first.stderr)).toBe(false); + expect(await run(join(project, "routes-src"))).toEqual(expected); + + const second = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-src"], + project, + cacheDir, + ); + expect(second.code).toBe(0); + expect(cacheHit(second.stderr)).toBe(true); + + // The manifest (its lock digest) is part of the cache identity. + const manifest = JSON.parse( + readFileSync(join(project, ".scriptc-pm/manifest.json"), "utf8"), + ) as { lockDigest: string }; + manifest.lockDigest = "sha256-other"; + const rekeyed = join(root, "rekeyed-manifest.json"); + writeFileSync(rekeyed, JSON.stringify(manifest)); + const third = await scriptc( + ["build", "routes.ts", `--sources=${rekeyed}`, "-o", "routes-src"], + project, + cacheDir, + ); + expect(third.code).toBe(0); + expect(cacheHit(third.stderr)).toBe(false); + expect(await run(join(project, "routes-src"))).toEqual(expected); + }); + + test("coverage attributes overlay statements to their package", async () => { + const coverage = await scriptc(["coverage", "routes.ts", "--sources"], project, cacheDir); + expect(coverage.code).toBe(0); + expect(coverage.stdout).toMatch(/path-to-regexp@6\.3\.0\s+static from source/); + expect(coverage.stdout).toContain( + "path-to-regexp@6.3.0 github.com/pillarjs/path-to-regexp @ 75a92c3d7c42", + ); + expect(coverage.stdout).toMatch(/\d+ statements: \d+ static \S*\(100%\)/); + }); + + test("overlays that do not compile fall back to published JS, per package", async () => { + // @openfeature/server-sdk@1.18.0 was built against its monorepo's core + // sources at 1.8.0, so its source does not typecheck against the + // installed core 1.9.2; core's own source redeclares Error#cause, which + // lowering refuses. Both fall back, and the build is the published one. + const withSources = await scriptc( + ["build", "flags.ts", "--sources", "-o", "flags-src"], + project, + cacheDir, + ); + expect(withSources.code).toBe(0); + expect(withSources.stderr).toMatch( + /sources: @openfeature\/server-sdk@1\.18\.0 compiles from published JS \(source overlay dropped: SC0001 at src\/client\/internal\/open-feature-client\.ts:\d+/, + ); + expect(withSources.stderr).toMatch( + /sources: @openfeature\/core@1\.9\.2 compiles from published JS \(source overlay dropped: SC1090 at src\/errors\/open-feature-error-abstract\.ts:\d+/, + ); + const published = await scriptc(["build", "flags.ts", "-o", "flags-pub"], project, cacheDir); + expect(published.code).toBe(0); + expect(await run(join(project, "flags-src"))).toEqual(await run(join(project, "flags-pub"))); + }); + + test("a consumer that only fails against the source surface falls back", async () => { + // mitt's declared Emitter interface (generic overloads) does not lower + // in the program; its published (minified) JS types by inference and + // does. The failure is in the program, not the overlay, so the + // published-surface probe decides. + const expected = await node("emit.ts"); + const built = await scriptc( + ["build", "emit.ts", "--sources", "-o", "emit-src"], + project, + cacheDir, + ); + expect(built.code).toBe(0); + expect(built.stderr).toMatch( + /sources: mitt@3\.0\.1 compiles from published JS \(source overlay dropped: the program does not compile against its source surface \(SC2009/, + ); + expect(await run(join(project, "emit-src"))).toEqual(expected); + }); + + test("a broken overlay and a require() edge each fall back", async () => { + const broken = materialize(join(root, "broken")); + appendFileSync( + join(broken, ".scriptc-pm/overlay/path-to-regexp@6.3.0/pkg/src/index.ts"), + '\nexport const broken: number = "not a number";\n', + ); + const fallback = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-fb"], + broken, + cacheDir, + ); + expect(fallback.code).toBe(0); + expect(fallback.stderr).toMatch( + /sources: path-to-regexp@6\.3\.0 compiles from published JS \(source overlay dropped: SC0001 at src\/index\.ts:\d+: Type 'string' is not assignable to type 'number'/, + ); + const published = await scriptc(["build", "routes.ts", "-o", "routes-pub"], broken, cacheDir); + expect(published.code).toBe(0); + expect(await run(join(broken, "routes-fb"))).toEqual(await run(join(broken, "routes-pub"))); + + // The published CommonJS build is what require() loads under Node, so + // the overlay steps aside; the build then fares exactly as without it. + const required = await scriptc( + ["build", "require.cts", "--sources", "-o", "require-src"], + project, + cacheDir, + ); + expect(required.stderr).toMatch( + /sources: path-to-regexp@6\.3\.0 compiles from published JS \(source overlay dropped: require\(\) of 'path-to-regexp'/, + ); + const requiredPublished = await scriptc( + ["build", "require.cts", "-o", "require-pub"], + project, + cacheDir, + ); + expect(required.code).toBe(requiredPublished.code); + }); +}); From 0c8946c25cceb2c5e79136ba99f2e78474d28304 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:51:13 -0700 Subject: [PATCH 2/2] Unit-test the source manifest reader and overlay resolver Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/source-overlays.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/compiler/src/frontend/source-overlays.test.ts diff --git a/packages/compiler/src/frontend/source-overlays.test.ts b/packages/compiler/src/frontend/source-overlays.test.ts new file mode 100644 index 0000000000..5dd2924679 --- /dev/null +++ b/packages/compiler/src/frontend/source-overlays.test.ts @@ -0,0 +1,124 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { normalizeCliArgs } from "../cli/usage.js"; +import { clearResolveCaches, resolveSourceOverlayModule, sourceOverlayPaths } from "./resolve.js"; +import { + activeSourceOverlays, + beginSourceOverlayCompile, + dropSourceOverlay, + findSourceManifest, + installSourceOverlays, + loadSourceManifest, + sourceOverlayFallbacks, + takeSourceOverlayProblems, +} from "./source-overlays.js"; + +let root: string; + +/** A pnpm-shaped install of `name` with an overlay beside it. */ +function instance(name: string, version: string): Record { + const installedDir = join(root, "node_modules/.pnpm", `${name}@${version}`, "node_modules", name); + mkdirSync(installedDir, { recursive: true }); + writeFileSync(join(installedDir, "package.json"), JSON.stringify({ name, version })); + const overlayDir = join(root, ".scriptc-pm/overlay", `${name}@${version}`, "pkg"); + mkdirSync(join(overlayDir, "src"), { recursive: true }); + writeFileSync(join(overlayDir, "src/index.ts"), "export const x = 1;\n"); + return { + installedDir, + name, + version, + overlayDir, + exports: { ".": "./src/index.ts", "./sub/*": "./src/*.ts" }, + }; +} + +function writeManifest(instances: Record[], extra = {}): string { + const path = join(root, ".scriptc-pm/manifest.json"); + mkdirSync(join(root, ".scriptc-pm"), { recursive: true }); + writeFileSync(path, JSON.stringify({ schema: 1, lockDigest: "sha256-a", instances, ...extra })); + return path; +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-overlays-"))); +}); + +afterEach(() => { + installSourceOverlays(null); + clearResolveCaches(); + rmSync(root, { recursive: true, force: true }); +}); + +test("reads schema 1, keys instances by installed realpath, and finds the manifest", () => { + const path = writeManifest([instance("a", "1.0.0")], { + fallbacks: [{ name: "b", version: "2.0.0", reason: "JavaScript upstream" }], + }); + mkdirSync(join(root, "src/deep"), { recursive: true }); + expect(findSourceManifest(join(root, "src/deep/main.ts"))).toBe(path); + const set = loadSourceManifest(path); + expect(set.instances.map((i) => i.key)).toEqual([ + join(root, "node_modules/.pnpm/a@1.0.0/node_modules/a"), + ]); + expect(set.notes).toEqual(["b@2.0.0: published JS (JavaScript upstream)"]); + expect(set.cacheIdentity).toMatch(/^sha256-a\0[0-9a-f]{64}$/); +}); + +test("rejects other schemas and malformed instances", () => { + writeManifest([]); + const path = join(root, ".scriptc-pm/manifest.json"); + writeFileSync(path, JSON.stringify({ schema: 2, lockDigest: "x", instances: [] })); + expect(() => loadSourceManifest(path)).toThrow(/unsupported schema 2/); + writeFileSync(path, JSON.stringify({ schema: 1, lockDigest: "x", instances: [{ name: "a" }] })); + expect(() => loadSourceManifest(path)).toThrow(/instances\[0\]\.installedDir/); +}); + +test("redirects imports that land in an instance, and the checker paths agree", () => { + const a = instance("a", "1.0.0"); + installSourceOverlays(loadSourceManifest(writeManifest([a]))); + beginSourceOverlayCompile(); + symlinkSync( + join(root, "node_modules/.pnpm/a@1.0.0/node_modules/a"), + join(root, "node_modules/a"), + ); + const main = join(root, "main.ts"); + const overlay = a["overlayDir"] as string; + expect(resolveSourceOverlayModule(main, "a")).toBe(join(overlay, "src/index.ts")); + expect(resolveSourceOverlayModule(main, "a/sub/index")).toBe(join(overlay, "src/index.ts")); + expect(sourceOverlayPaths()).toEqual({ + a: [join(overlay, "src/index.ts")], + "a/sub/*": [join(overlay, "src/*.ts")], + }); + // A subpath the overlay does not export is a problem the frontend drops for. + expect(resolveSourceOverlayModule(main, "a/missing")).toBeNull(); + expect([...takeSourceOverlayProblems().values()]).toEqual([ + "'a/missing' is not exported by the overlay", + ]); + dropSourceOverlay(a["installedDir"] as string, "test"); + expect(resolveSourceOverlayModule(main, "a")).toBeNull(); + expect(sourceOverlayPaths()).toBeNull(); + beginSourceOverlayCompile(); + expect(activeSourceOverlays()).toHaveLength(1); +}); + +test("names with several augmented instances start dropped", () => { + const one = instance("a", "1.0.0"); + const two = instance("a", "2.0.0"); + installSourceOverlays(loadSourceManifest(writeManifest([one, two]))); + beginSourceOverlayCompile(); + expect(activeSourceOverlays()).toEqual([]); + expect(sourceOverlayFallbacks().map((f) => f.reason)).toEqual([ + "ambiguous-name: 2 augmented instances of a (only single-instance names are redirected)", + "ambiguous-name: 2 augmented instances of a (only single-instance names are redirected)", + ]); +}); + +test("--sources takes an optional inline value", () => { + expect(normalizeCliArgs(["build", "--sources", "m.ts"])).toEqual(["build", "--sources=", "m.ts"]); + expect(normalizeCliArgs(["--sources=x.json", "--", "--sources"])).toEqual([ + "--sources=x.json", + "--", + "--sources", + ]); +});