Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/local-flagship-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": minor
---

Simulate Flagship bindings locally

Flagship bindings can now evaluate flags against a persisted local store instead of requiring a remote app. Miniflare also exposes an admin API for populating and managing that store in development tools and tests, while bindings configured for remote access continue to proxy to Flagship.
37 changes: 37 additions & 0 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
SharedHeaders,
SiteBindings,
} from "./workers";
import { ADMIN_API as FLAGSHIP_ADMIN_API } from "./workers/flagship/constants";
import { ADMIN_API } from "./workers/secrets-store/constants";
import type {
MiniflareOptions,
Expand Down Expand Up @@ -170,6 +171,9 @@ import type {
} from "./shared/dev-control";
import type { WorkerDefinition } from "./shared/dev-registry-types";
import type { Awaitable } from "./workers";
import type { FlagshipAdmin } from "./workers/flagship/admin";
import type { EvaluationDetails, FlagValue } from "./workers/flagship/evaluate";
import type { Flag, FlagInput } from "./workers/flagship/flags";
import type {
CacheStorage,
D1Database,
Expand Down Expand Up @@ -3465,6 +3469,17 @@ export class Miniflare {
): Promise<Flagship> {
return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName);
}
getFlagshipBindingAPI(
bindingName: string,
workerName?: string
): Promise<() => FlagshipAdmin> {
return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName).then(
(binding) => {
// @ts-expect-error We exposed an admin API on this key
return binding[FLAGSHIP_ADMIN_API];
}
);
}
getStreamBinding(
bindingName: string,
workerName?: string
Expand Down Expand Up @@ -3625,6 +3640,28 @@ export class Miniflare {

export type { WorkerdStructuredLog } from "./plugins/core";

export type { FlagshipAdmin } from "./workers/flagship/admin";

export type {
BaseCondition,
Condition,
ErrorCode,
LogicalCondition,
EvaluationContext,
EvaluationDetails,
EvaluationReason,
FlagValue,
Operator,
Rollout,
} from "./workers/flagship/evaluate";
export type {
Flag,
FlagChanges,
FlagInput,
FlagType,
Rule,
} from "./workers/flagship/flags";

export interface SecretsStoreSecretAdmin {
create(value: string): Promise<string>;
update(value: string, id: string): Promise<string>;
Expand Down
134 changes: 117 additions & 17 deletions packages/miniflare/src/plugins/flagship/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,56 @@
import fs from "node:fs/promises";
import BINDING_SCRIPT from "worker:flagship/binding";
import OBJECT_SCRIPT from "worker:flagship/object";
import {
buildRemoteProxyProps,
getEnvBindingsOfType,
getPersistPath,
getRemoteProxyConnectionString,
getUserBindingServiceName,
ProxyNodeBinding,
remoteProxyClientWorker,
} from "../shared";
import type { Worker_Binding } from "../../runtime";
import type { Service, Worker_Binding } from "../../runtime";
import type { Plugin } from "../shared";

export const FLAGSHIP_PLUGIN_NAME = "flagship";
const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`;
const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:remote`;
const FLAGSHIP_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:object`;
const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:storage`;
const FLAGSHIP_OBJECT_CLASS_NAME = "FlagshipObject";

// Rollout bucketing is seeded with the account tag. Local flag definitions are
// their own source of truth and an account tag is not reliably available
// offline, so a constant keeps bucketing deterministic across machines; it does
// not reproduce production buckets.
const LOCAL_ACCOUNT_TAG = "local";

export const FLAGSHIP_PLUGIN: Plugin = {
bindingTypeDescription: "Flagship",
async getBindings(options) {
return getEnvBindingsOfType(options.config, "flagship").map<Worker_Binding>(
([name, binding]) => ({
name,
service: {
name: FLAGSHIP_REMOTE_SERVICE_NAME,
props: buildRemoteProxyProps(
getRemoteProxyConnectionString(binding, options.dev),
name
),
},
})
([name, binding]) => {
const remoteProxyConnectionString = getRemoteProxyConnectionString(
binding,
options.dev
);
if (remoteProxyConnectionString) {
return {
name,
service: {
name: FLAGSHIP_REMOTE_SERVICE_NAME,
props: buildRemoteProxyProps(remoteProxyConnectionString, name),
},
};
}
return {
name,
service: {
name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, binding.id),
entrypoint: "FlagshipBinding",
},
};
}
);
},
getNodeBindings(options) {
Expand All @@ -35,16 +61,90 @@ export const FLAGSHIP_PLUGIN: Plugin = {
])
);
},
async getServices({ options }) {
if (getEnvBindingsOfType(options.config, "flagship").length === 0) {
async getServices({ options, tmpPath, sharedOptions }) {
const bindings = getEnvBindingsOfType(options.config, "flagship");
if (bindings.length === 0) {
return [];
}

return [
{
const services: Service[] = [];
const hasRemote = bindings.some(([, binding]) =>
getRemoteProxyConnectionString(binding, options.dev)
);
if (hasRemote) {
services.push({
name: FLAGSHIP_REMOTE_SERVICE_NAME,
worker: remoteProxyClientWorker(),
});
}

const localAppIds = new Set(
bindings
.filter(
([, binding]) =>
getRemoteProxyConnectionString(binding, options.dev) === undefined
)
.map(([, binding]) => binding.id)
);
if (localAppIds.size === 0) {
return services;
}

const persistPath = getPersistPath(
FLAGSHIP_PLUGIN_NAME,
tmpPath,
sharedOptions.resourcePersistencePath
);
await fs.mkdir(persistPath, { recursive: true });

services.push(
{
name: FLAGSHIP_STORAGE_SERVICE_NAME,
disk: { path: persistPath, writable: true },
},
];
{
name: FLAGSHIP_OBJECT_SERVICE_NAME,
worker: {
compatibilityDate: "2025-01-01",
modules: [{ name: "object.worker.js", esModule: OBJECT_SCRIPT() }],
durableObjectNamespaces: [
{
className: FLAGSHIP_OBJECT_CLASS_NAME,
uniqueKey: `miniflare-flagship-${FLAGSHIP_OBJECT_CLASS_NAME}`,
enableSql: true,
},
],
durableObjectStorage: { localDisk: FLAGSHIP_STORAGE_SERVICE_NAME },
},
}
);

for (const appId of localAppIds) {
services.push({
name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, appId),
worker: {
compatibilityDate: "2025-01-01",
modules: [{ name: "binding.worker.js", esModule: BINDING_SCRIPT() }],
bindings: [
{
name: "config",
json: JSON.stringify({
appId,
accountTag: LOCAL_ACCOUNT_TAG,
}),
},
{
name: "store",
durableObjectNamespace: {
className: FLAGSHIP_OBJECT_CLASS_NAME,
serviceName: FLAGSHIP_OBJECT_SERVICE_NAME,
},
},
],
},
});
}

return services;
},
};
23 changes: 23 additions & 0 deletions packages/miniflare/src/workers/flagship/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {
EvaluationContext,
EvaluationDetails,
FlagValue,
} from "./evaluate";
import type { Flag, FlagChanges, FlagInput } from "./flags";

export interface FlagshipAdmin {
listFlags(): Promise<Flag[]>;
getFlag(flagKey: string): Promise<Flag>;
getAccountTag(): Promise<string | null>;
setAccountTag(accountTag: string): Promise<void>;
createFlag(input: FlagInput): Promise<Flag>;
updateFlag(flagKey: string, input: FlagInput): Promise<Flag>;
patchFlag(flagKey: string, changes: FlagChanges): Promise<Flag>;
putFlag(input: FlagInput): Promise<Flag>;
putFlags(inputs: FlagInput[], accountTag: string): Promise<void>;
deleteFlag(flagKey: string): Promise<void>;
evaluateFlag(
flagKey: string,
context?: EvaluationContext
): Promise<EvaluationDetails<FlagValue>>;
}
Loading
Loading