From 72387aafa97528df4eb5e86063963431447dff79 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 19 Jun 2026 00:49:47 +0700 Subject: [PATCH 1/2] refactor: extract engine and shared schemas into @flaggly/engine package - Move src/engine.ts and shared schema types into new engine/ workspace package - Create engine/package.json with tsup build matching sdk pattern - Set up pnpm workspaces for root, sdk, and engine - Update all src/ imports to use @flaggly/engine via workspace:* - Move engine tests into engine/__tests__/ and update import paths --- {src => engine}/__tests__/engine.spec.ts | 4 +- {src => engine}/__tests__/example.spec.ts | 4 +- {src => engine}/engine.ts | 0 engine/index.ts | 2 + engine/package.json | 28 ++++ engine/schema.ts | 185 ++++++++++++++++++++++ engine/tsconfig.engine.json | 20 +++ package.json | 6 +- pnpm-lock.yaml | 41 +++++ pnpm-workspace.yaml | 4 + src/__tests__/storage.spec.ts | 2 +- src/routes/admin.ts | 2 +- src/routes/api.ts | 4 +- src/routes/ui.tsx | 5 +- src/schema.ts | 180 ++------------------- src/storage.ts | 3 +- tsconfig.json | 9 +- 17 files changed, 311 insertions(+), 188 deletions(-) rename {src => engine}/__tests__/engine.spec.ts (99%) rename {src => engine}/__tests__/example.spec.ts (99%) rename {src => engine}/engine.ts (100%) create mode 100644 engine/index.ts create mode 100644 engine/package.json create mode 100644 engine/schema.ts create mode 100644 engine/tsconfig.engine.json create mode 100644 pnpm-workspace.yaml diff --git a/src/__tests__/engine.spec.ts b/engine/__tests__/engine.spec.ts similarity index 99% rename from src/__tests__/engine.spec.ts rename to engine/__tests__/engine.spec.ts index 86afd5d..cbb2c48 100644 --- a/src/__tests__/engine.spec.ts +++ b/engine/__tests__/engine.spec.ts @@ -3,8 +3,8 @@ import { evaluateFlag, evaluateRolloutStep, evaluateRolloutSteps, -} from "../engine"; -import type { FeatureFlagInputSchema, FlagEvaluationInput } from "../schema"; +} from "../engine.ts"; +import type { FeatureFlagInputSchema, FlagEvaluationInput } from "../schema.ts"; function createMockInput( overrides?: Partial, diff --git a/src/__tests__/example.spec.ts b/engine/__tests__/example.spec.ts similarity index 99% rename from src/__tests__/example.spec.ts rename to engine/__tests__/example.spec.ts index 603d2af..b506b5d 100644 --- a/src/__tests__/example.spec.ts +++ b/engine/__tests__/example.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { evaluateFlag } from "../engine"; -import type { FlagEvaluationInput } from "../schema"; +import { evaluateFlag } from "../engine.ts"; +import type { FlagEvaluationInput } from "../schema.ts"; /** * Helper to create properly structured evaluation input diff --git a/src/engine.ts b/engine/engine.ts similarity index 100% rename from src/engine.ts rename to engine/engine.ts diff --git a/engine/index.ts b/engine/index.ts new file mode 100644 index 0000000..9f9dda1 --- /dev/null +++ b/engine/index.ts @@ -0,0 +1,2 @@ +export * from "./engine"; +export * from "./schema"; diff --git a/engine/package.json b/engine/package.json new file mode 100644 index 0000000..2e99c56 --- /dev/null +++ b/engine/package.json @@ -0,0 +1,28 @@ +{ + "name": "@flaggly/engine", + "version": "0.2.0", + "description": "Flaggly feature flag evaluation engine", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "feature-flags", + "engine", + "typescript", + "cloudflare" + ], + "author": "butttons", + "license": "MIT", + "dependencies": { + "jexl": "^2.3.0", + "zod": "^4.1.11" + }, + "repository": { + "type": "git", + "url": "https://github.com/butttons/flaggly" + } +} diff --git a/engine/schema.ts b/engine/schema.ts new file mode 100644 index 0000000..0c6ae70 --- /dev/null +++ b/engine/schema.ts @@ -0,0 +1,185 @@ +import { + catch as _catch, + _default, + array, + boolean, + discriminatedUnion, + type infer as Infer, + literal, + maximum, + minimum, + minLength, + nullable, + number, + object, + optional, + record, + refine, + string, + unknown, +} from "zod/v4-mini"; + +export const flagRule = string().check( + minLength(1, { error: "JEXL rule is required" }), +); + +export const featureFlagVariationSchema = object({ + id: string().check(minLength(1, { error: "Variation ID required" })), + label: optional(string()), + weight: number().check(minimum(0), maximum(100)), + payload: optional(unknown()), +}); + +export const rolloutStep = object({ + start: string(), // ISO 8601 date string + percentage: optional(number().check(minimum(0), maximum(100))), + segment: optional(string()), +}).check( + refine( + (step) => step.percentage !== undefined || step.segment !== undefined, + { + error: "Each rollout step must define either percentage or segment", + }, + ), +); +export type RolloutStep = Infer; + +export const baseFeatureFlag = { + id: string().check(minLength(1, { error: "Flag key is required" })), + label: optional(string()), + description: optional(string()), + enabled: _default(optional(boolean()), false), + rules: _default(array(flagRule), []), + rollout: _default(number().check(minimum(0), maximum(100)), 100), + rollouts: _default(array(rolloutStep), []), + isTrackable: _default(boolean(), false), +}; + +export const inputFeatureFlag = { + ...baseFeatureFlag, + segments: _default(array(string()), []), +}; + +export const booleanFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("boolean"), +}); + +export const payloadFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("payload"), + payload: unknown(), +}); + +export const variantFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("variant"), + variations: array(featureFlagVariationSchema).check( + minLength(2, "At least must have 2 variants"), + ), +}); + +export const inputFeatureFlagSchema = discriminatedUnion("type", [ + booleanFeatureFlag, + payloadFeatureFlag, + variantFeatureFlag, +]).check( + refine( + (x) => + !( + x.type === "boolean" && + // @ts-expect-error we want to check for run time input + (x?.variations !== undefined || x?.payload !== undefined) + ), + { + error: "Boolean flags cannot have a payload", + }, + ), + refine((x) => !(x.type === "payload" && x.payload === undefined), { + error: "Payload flags must have a payload", + }), + refine((x) => !(x.type === "variant" && x.variations === undefined), { + error: "Variant flags must have at least 2 variations", + }), +); + +export type FeatureFlagInputSchema = Infer; + +export type AppData = { + flags: Record; + segments: Record; +}; + +export type FeatureFlagOutputSchema = Omit< + FeatureFlagInputSchema, + "segments" +> & { + segments: Record; +}; + +export const requestGeoSchema = _catch( + object({ + country: optional(string()), + + isEUCountry: _catch(optional(boolean()), false), + + continent: optional(string()), + city: optional(string()), + postalCode: optional(string()), + + latitude: optional(string()), + longitude: optional(string()), + timezone: optional(string()), + + region: optional(string()), + regionCode: optional(string()), + metroCode: optional(string()), + }), + { + isEUCountry: false, + }, +); + +export const evaluateInputSchema = object({ + user: optional(unknown()), + id: optional(string()), + request: object({ + headers: record(string(), string()), + }), + page: object({ + url: nullable(string()), + }), + geo: requestGeoSchema, +}); + +export type FlagEvaluationInput = Infer; + +const booleanFlagResult = object({ + type: literal("boolean"), + result: boolean(), + isEval: boolean(), +}); + +const payloadFlagResult = object({ + type: literal("payload"), + result: unknown(), + isEval: boolean(), +}); + +const variantFlagResult = object({ + type: literal("variant"), + result: unknown(), + isEval: boolean(), +}); + +export const evaluateOutputSchema = discriminatedUnion("type", [ + booleanFlagResult, + payloadFlagResult, + variantFlagResult, +]); +export type FlagResultSchema = Infer; + +export const evaluateBatchOutputSchema = record( + string(), + nullable(evaluateOutputSchema), +); diff --git a/engine/tsconfig.engine.json b/engine/tsconfig.engine.json new file mode 100644 index 0000000..9435683 --- /dev/null +++ b/engine/tsconfig.engine.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "../../dist/engine", + "rootDir": "../", + "strict": true, + "strictNullChecks": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["./**/*.ts"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "node_modules"] +} diff --git a/package.json b/package.json index 113fb59..542e0de 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,14 @@ "test": "vitest", "test:run": "vitest run", "build:sdk": "tsup sdk/index.ts --dts --format esm,cjs --tsconfig sdk/tsconfig.sdk.json --out-dir sdk/dist", - "sandbox:sdk": "tsx sdk/sandbox.ts" + "sandbox:sdk": "tsx sdk/sandbox.ts", + "build:engine": "tsup engine/index.ts --dts --format esm,cjs --tsconfig engine/tsconfig.engine.json --out-dir engine/dist" }, "dependencies": { "hono": "^4.9.9", "jexl": "^2.3.0", - "zod": "^4.1.11" + "zod": "^4.1.11", + "@flaggly/engine": "workspace:*" }, "devDependencies": { "@types/jexl": "^2.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ca91a..8fe0485 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@flaggly/engine': + specifier: workspace:* + version: link:engine hono: specifier: ^4.9.9 version: 4.9.9 @@ -46,6 +49,21 @@ importers: specifier: ^4.40.2 version: 4.40.2(@cloudflare/workers-types@4.20250927.0) + engine: + dependencies: + jexl: + specifier: ^2.3.0 + version: 2.3.0 + zod: + specifier: ^4.1.11 + version: 4.1.11 + + sdk: + dependencies: + nanostores: + specifier: ^1.0.1 + version: 1.0.1 + packages: '@babel/runtime@7.28.4': @@ -281,67 +299,79 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -427,56 +457,67 @@ packages: resolution: {integrity: sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.3': resolution: {integrity: sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.3': resolution: {integrity: sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.3': resolution: {integrity: sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.3': resolution: {integrity: sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.3': resolution: {integrity: sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.3': resolution: {integrity: sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.3': resolution: {integrity: sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.3': resolution: {integrity: sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.3': resolution: {integrity: sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.3': resolution: {integrity: sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.3': resolution: {integrity: sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a83a40c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - "." + - "sdk" + - "engine" diff --git a/src/__tests__/storage.spec.ts b/src/__tests__/storage.spec.ts index 67f9706..d9867d5 100644 --- a/src/__tests__/storage.spec.ts +++ b/src/__tests__/storage.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { FeatureFlagInputSchema } from "../schema"; +import type { FeatureFlagInputSchema } from "@flaggly/engine"; import { AppKV } from "../storage"; function createMockKV(): KVNamespace { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 0426256..2ed7a04 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -2,8 +2,8 @@ import { jwt } from "hono/jwt"; import { validator } from "hono/validator"; import { object, string } from "zod/v4-mini"; import { FlagglyError } from "../error"; +import { inputFeatureFlagSchema } from "@flaggly/engine"; import { - inputFeatureFlagSchema, segmentInputSchema, syncInputSchema, updateableFeatureFlagSchema, diff --git a/src/routes/api.ts b/src/routes/api.ts index 378069d..b6be3fa 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -2,9 +2,9 @@ import { jwt } from "hono/jwt"; import { validator } from "hono/validator"; import { omit } from "zod/v4-mini"; -import { evaluateFlag } from "../engine"; +import { evaluateFlag, evaluateInputSchema, requestGeoSchema } from "@flaggly/engine"; import { FlagglyError } from "../error"; -import { evaluateInputSchema, paramSchema, requestGeoSchema } from "../schema"; +import { paramSchema } from "../schema"; import { createApp } from "./_app"; export const api = createApp(); diff --git a/src/routes/ui.tsx b/src/routes/ui.tsx index 501bb0f..f36b249 100644 --- a/src/routes/ui.tsx +++ b/src/routes/ui.tsx @@ -1,7 +1,8 @@ import { html } from "hono/html"; import type { FC } from "hono/jsx"; -import type { AppData, FeatureFlagInputSchema } from "../schema"; -import { inputFeatureFlagSchema, segmentInputSchema } from "../schema"; +import type { AppData, FeatureFlagInputSchema } from "@flaggly/engine"; +import { inputFeatureFlagSchema } from "@flaggly/engine"; +import { segmentInputSchema } from "../schema"; import { buildUrl, Layout } from "../components/Layout"; import { createApp } from "./_app"; diff --git a/src/schema.ts b/src/schema.ts index 5ce5224..7922667 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,16 +1,13 @@ import { catch as _catch, _default, - enum as _enum, array, boolean, - discriminatedUnion, + enum as _enum, type infer as Infer, - literal, - maximum, - minimum, minLength, - nullable, + minimum, + maximum, number, object, optional, @@ -20,9 +17,11 @@ import { unknown, } from "zod/v4-mini"; -export const flagRule = string().check( - minLength(1, { error: "JEXL rule is required" }), -); +import { + featureFlagVariationSchema, + flagRule, + rolloutStep, +} from "@flaggly/engine"; export const DEFAULT_ENV = ["production", "staging", "development"]; @@ -58,100 +57,6 @@ export const appSchema = object({ }); export type AppSchema = Infer; -export const featureFlagVariationSchema = object({ - id: string().check(minLength(1, { error: "Variation ID required" })), - label: optional(string()), - weight: number().check(minimum(0), maximum(100)), - payload: optional(unknown()), -}); - -export const rolloutStep = object({ - start: string(), // ISO 8601 date string - percentage: optional(number().check(minimum(0), maximum(100))), - segment: optional(string()), -}).check( - refine( - (step) => step.percentage !== undefined || step.segment !== undefined, - { - error: "Each rollout step must define either percentage or segment", - }, - ), -); -export type RolloutStep = Infer; - -export const baseFeatureFlag = { - id: string().check(minLength(1, { error: "Flag key is required" })), - label: optional(string()), - description: optional(string()), - enabled: _default(optional(boolean()), false), - rules: _default(array(flagRule), []), - rollout: _default(number().check(minimum(0), maximum(100)), 100), - rollouts: _default(array(rolloutStep), []), - isTrackable: _default(boolean(), false), -}; - -export const inputFeatureFlag = { - ...baseFeatureFlag, - segments: _default(array(string()), []), -}; - -export const booleanFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("boolean"), -}); - -export const payloadFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("payload"), - payload: unknown(), -}); - -export const variantFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("variant"), - variations: array(featureFlagVariationSchema).check( - minLength(2, "At least must have 2 variants"), - ), -}); - -export const inputFeatureFlagSchema = discriminatedUnion("type", [ - booleanFeatureFlag, - payloadFeatureFlag, - variantFeatureFlag, -]).check( - refine( - (x) => - !( - x.type === "boolean" && - // @ts-expect-error we want to check for run time input - (x?.variations !== undefined || x?.payload !== undefined) - ), - { - error: "Boolean flags cannot have a payload", - }, - ), - refine((x) => !(x.type === "payload" && x.payload === undefined), { - error: "Payload flags must have a payload", - }), - refine((x) => !(x.type === "variant" && x.variations === undefined), { - error: "Variant flags must have at least 2 variations", - }), -); - -export type FeatureFlagInputSchema = Infer; - -export type AppData = { - flags: Record; - segments: Record; -}; - -export type FeatureFlagOutputSchema = Omit< - FeatureFlagInputSchema, - "segments" -> & { - segments: Record; -}; - export const updateableFeatureFlagSchema = object({ label: optional(string()), description: optional(string()), @@ -164,7 +69,7 @@ export const updateableFeatureFlagSchema = object({ ), rollout: optional(number().check(minimum(0), maximum(100))), rollouts: optional(array(rolloutStep)), - type: optional(_enum(["boolean", "payload", "variant"])), + type: optional(_enum(["boolean", "payload", "variant"])), payload: optional(unknown()), variations: optional( array(featureFlagVariationSchema).check( @@ -226,73 +131,6 @@ export const baseInputSchema = { ), }; -export const requestGeoSchema = _catch( - object({ - country: optional(string()), - - isEUCountry: _catch(optional(boolean()), false), - - continent: optional(string()), - city: optional(string()), - postalCode: optional(string()), - - latitude: optional(string()), - longitude: optional(string()), - timezone: optional(string()), - - region: optional(string()), - regionCode: optional(string()), - metroCode: optional(string()), - }), - { - isEUCountry: false, - }, -); - -export const evaluateInputSchema = object({ - user: optional(unknown()), - id: optional(string()), - request: object({ - headers: record(string(), string()), - }), - page: object({ - url: nullable(string()), - }), - geo: requestGeoSchema, -}); - -export type FlagEvaluationInput = Infer; - -const booleanFlagResult = object({ - type: literal("boolean"), - result: boolean(), - isEval: boolean(), -}); - -const payloadFlagResult = object({ - type: literal("payload"), - result: unknown(), - isEval: boolean(), -}); - -const variantFlagResult = object({ - type: literal("variant"), - result: unknown(), - isEval: boolean(), -}); - -export const evaluateOutputSchema = discriminatedUnion("type", [ - booleanFlagResult, - payloadFlagResult, - variantFlagResult, -]); -export type FlagResultSchema = Infer; - -export const evaluateBatchOutputSchema = record( - string(), - nullable(evaluateOutputSchema), -); - export const paramSchema = object({ id: string(), }); diff --git a/src/storage.ts b/src/storage.ts index 5ec6de4..b976bf2 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,7 +1,6 @@ import { FlagglyError, tryPromise } from "./error"; +import type { AppData, FeatureFlagInputSchema } from "@flaggly/engine"; import type { - AppData, - FeatureFlagInputSchema, SegmentInputSchema, SyncInput, UpdatableFeatureFlagSchema, diff --git a/tsconfig.json b/tsconfig.json index 9404c61..7649fb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,8 +25,11 @@ "./worker-configuration.d.ts", "@types/node", "@types/service-worker-mock" - ] + ], + "paths": { + "@flaggly/engine": ["./engine/index.ts"] + } }, - "exclude": ["node_modules", "dist", "tests"], - "include": ["src", "worker-configuration.d.ts"] + "exclude": ["node_modules", "dist", "tests", "engine/dist", "**/*.spec.ts"], + "include": ["src", "engine", "worker-configuration.d.ts"] } From 81ae65b5ad84360ff412b828665c4fdfa0635ae9 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 19 Jun 2026 00:59:11 +0700 Subject: [PATCH 2/2] refactor(engine): remove zod schemas and replace with plain types.ts - Remove engine/schema.ts and zod dependency from engine package - Add engine/types.ts with plain TypeScript types duplicated from schemas - Restore src/schema.ts to original full zod schemas - Update all src/ imports to use local schema.ts again - Engine package only exports evaluation logic + plain types - Add exports field to engine/package.json for proper module resolution --- engine/__tests__/engine.spec.ts | 2 +- engine/__tests__/example.spec.ts | 2 +- engine/engine.ts | 2 +- engine/index.ts | 2 +- engine/package.json | 10 +- engine/schema.ts | 185 ------------------------------- engine/types.ts | 98 ++++++++++++++++ pnpm-lock.yaml | 13 +++ pnpm-workspace.yaml | 1 + src/__tests__/storage.spec.ts | 2 +- src/routes/admin.ts | 2 +- src/routes/api.ts | 3 +- src/routes/ui.tsx | 4 +- src/schema.ts | 180 ++++++++++++++++++++++++++++-- src/storage.ts | 2 +- 15 files changed, 302 insertions(+), 206 deletions(-) delete mode 100644 engine/schema.ts create mode 100644 engine/types.ts diff --git a/engine/__tests__/engine.spec.ts b/engine/__tests__/engine.spec.ts index cbb2c48..873bcce 100644 --- a/engine/__tests__/engine.spec.ts +++ b/engine/__tests__/engine.spec.ts @@ -4,7 +4,7 @@ import { evaluateRolloutStep, evaluateRolloutSteps, } from "../engine.ts"; -import type { FeatureFlagInputSchema, FlagEvaluationInput } from "../schema.ts"; +import type { FeatureFlagInputSchema, FlagEvaluationInput } from "../types.ts"; function createMockInput( overrides?: Partial, diff --git a/engine/__tests__/example.spec.ts b/engine/__tests__/example.spec.ts index b506b5d..305791f 100644 --- a/engine/__tests__/example.spec.ts +++ b/engine/__tests__/example.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { evaluateFlag } from "../engine.ts"; -import type { FlagEvaluationInput } from "../schema.ts"; +import type { FlagEvaluationInput } from "../types.ts"; /** * Helper to create properly structured evaluation input diff --git a/engine/engine.ts b/engine/engine.ts index 9bc7ed2..10fa5e8 100644 --- a/engine/engine.ts +++ b/engine/engine.ts @@ -3,7 +3,7 @@ import type { FeatureFlagInputSchema, FlagEvaluationInput, FlagResultSchema, -} from "./schema"; +} from "./types"; jexl.addTransform("split", (val, char) => val.split(char)); jexl.addTransform("lower", (val) => val.toLowerCase()); diff --git a/engine/index.ts b/engine/index.ts index 9f9dda1..3df7a48 100644 --- a/engine/index.ts +++ b/engine/index.ts @@ -1,2 +1,2 @@ export * from "./engine"; -export * from "./schema"; +export * from "./types"; diff --git a/engine/package.json b/engine/package.json index 2e99c56..4cfca93 100644 --- a/engine/package.json +++ b/engine/package.json @@ -18,11 +18,17 @@ "author": "butttons", "license": "MIT", "dependencies": { - "jexl": "^2.3.0", - "zod": "^4.1.11" + "jexl": "^2.3.0" }, "repository": { "type": "git", "url": "https://github.com/butttons/flaggly" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } } } diff --git a/engine/schema.ts b/engine/schema.ts deleted file mode 100644 index 0c6ae70..0000000 --- a/engine/schema.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - catch as _catch, - _default, - array, - boolean, - discriminatedUnion, - type infer as Infer, - literal, - maximum, - minimum, - minLength, - nullable, - number, - object, - optional, - record, - refine, - string, - unknown, -} from "zod/v4-mini"; - -export const flagRule = string().check( - minLength(1, { error: "JEXL rule is required" }), -); - -export const featureFlagVariationSchema = object({ - id: string().check(minLength(1, { error: "Variation ID required" })), - label: optional(string()), - weight: number().check(minimum(0), maximum(100)), - payload: optional(unknown()), -}); - -export const rolloutStep = object({ - start: string(), // ISO 8601 date string - percentage: optional(number().check(minimum(0), maximum(100))), - segment: optional(string()), -}).check( - refine( - (step) => step.percentage !== undefined || step.segment !== undefined, - { - error: "Each rollout step must define either percentage or segment", - }, - ), -); -export type RolloutStep = Infer; - -export const baseFeatureFlag = { - id: string().check(minLength(1, { error: "Flag key is required" })), - label: optional(string()), - description: optional(string()), - enabled: _default(optional(boolean()), false), - rules: _default(array(flagRule), []), - rollout: _default(number().check(minimum(0), maximum(100)), 100), - rollouts: _default(array(rolloutStep), []), - isTrackable: _default(boolean(), false), -}; - -export const inputFeatureFlag = { - ...baseFeatureFlag, - segments: _default(array(string()), []), -}; - -export const booleanFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("boolean"), -}); - -export const payloadFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("payload"), - payload: unknown(), -}); - -export const variantFeatureFlag = object({ - ...inputFeatureFlag, - type: literal("variant"), - variations: array(featureFlagVariationSchema).check( - minLength(2, "At least must have 2 variants"), - ), -}); - -export const inputFeatureFlagSchema = discriminatedUnion("type", [ - booleanFeatureFlag, - payloadFeatureFlag, - variantFeatureFlag, -]).check( - refine( - (x) => - !( - x.type === "boolean" && - // @ts-expect-error we want to check for run time input - (x?.variations !== undefined || x?.payload !== undefined) - ), - { - error: "Boolean flags cannot have a payload", - }, - ), - refine((x) => !(x.type === "payload" && x.payload === undefined), { - error: "Payload flags must have a payload", - }), - refine((x) => !(x.type === "variant" && x.variations === undefined), { - error: "Variant flags must have at least 2 variations", - }), -); - -export type FeatureFlagInputSchema = Infer; - -export type AppData = { - flags: Record; - segments: Record; -}; - -export type FeatureFlagOutputSchema = Omit< - FeatureFlagInputSchema, - "segments" -> & { - segments: Record; -}; - -export const requestGeoSchema = _catch( - object({ - country: optional(string()), - - isEUCountry: _catch(optional(boolean()), false), - - continent: optional(string()), - city: optional(string()), - postalCode: optional(string()), - - latitude: optional(string()), - longitude: optional(string()), - timezone: optional(string()), - - region: optional(string()), - regionCode: optional(string()), - metroCode: optional(string()), - }), - { - isEUCountry: false, - }, -); - -export const evaluateInputSchema = object({ - user: optional(unknown()), - id: optional(string()), - request: object({ - headers: record(string(), string()), - }), - page: object({ - url: nullable(string()), - }), - geo: requestGeoSchema, -}); - -export type FlagEvaluationInput = Infer; - -const booleanFlagResult = object({ - type: literal("boolean"), - result: boolean(), - isEval: boolean(), -}); - -const payloadFlagResult = object({ - type: literal("payload"), - result: unknown(), - isEval: boolean(), -}); - -const variantFlagResult = object({ - type: literal("variant"), - result: unknown(), - isEval: boolean(), -}); - -export const evaluateOutputSchema = discriminatedUnion("type", [ - booleanFlagResult, - payloadFlagResult, - variantFlagResult, -]); -export type FlagResultSchema = Infer; - -export const evaluateBatchOutputSchema = record( - string(), - nullable(evaluateOutputSchema), -); diff --git a/engine/types.ts b/engine/types.ts new file mode 100644 index 0000000..3457bfe --- /dev/null +++ b/engine/types.ts @@ -0,0 +1,98 @@ +export type RolloutStep = { + start: string; + percentage?: number; + segment?: string; +}; + +export type FeatureFlagVariation = { + id: string; + label?: string; + weight: number; + payload?: unknown; +}; + +export type FeatureFlagInputSchema = + | { + id: string; + label?: string; + description?: string; + enabled: boolean; + type: "boolean"; + rules: string[]; + segments: string[]; + rollout: number; + rollouts: RolloutStep[]; + isTrackable: boolean; + } + | { + id: string; + label?: string; + description?: string; + enabled: boolean; + type: "payload"; + payload: unknown; + rules: string[]; + segments: string[]; + rollout: number; + rollouts: RolloutStep[]; + isTrackable: boolean; + } + | { + id: string; + label?: string; + description?: string; + enabled: boolean; + type: "variant"; + variations: FeatureFlagVariation[]; + rules: string[]; + segments: string[]; + rollout: number; + rollouts: RolloutStep[]; + isTrackable: boolean; + }; + +export type FlagEvaluationInput = { + user?: unknown; + id?: string; + request: { + headers: Record; + }; + page: { + url?: string | null; + }; + geo: { + country?: string; + isEUCountry?: boolean; + continent?: string; + city?: string; + postalCode?: string; + latitude?: string; + longitude?: string; + timezone?: string; + region?: string; + regionCode?: string; + metroCode?: string; + }; +}; + +export type FlagResultSchema = + | { + type: "boolean"; + result: boolean; + isEval: boolean; + } + | { + type: "payload"; + result: unknown; + isEval: boolean; + } + | { + type: "variant"; + result: unknown; + isEval: boolean; + }; + +export type AppData = { + flags: Record; + segments: Record; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fe0485..0ed18fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,19 @@ importers: specifier: ^4.1.11 version: 4.1.11 + playground: + dependencies: + '@flaggly/engine': + specifier: file:../engine + version: link:../engine + devDependencies: + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: ^5.9.2 + version: 5.9.2 + sdk: dependencies: nanostores: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a83a40c..7e62b45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - "." - "sdk" - "engine" + - "playground" diff --git a/src/__tests__/storage.spec.ts b/src/__tests__/storage.spec.ts index d9867d5..67f9706 100644 --- a/src/__tests__/storage.spec.ts +++ b/src/__tests__/storage.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { FeatureFlagInputSchema } from "@flaggly/engine"; +import type { FeatureFlagInputSchema } from "../schema"; import { AppKV } from "../storage"; function createMockKV(): KVNamespace { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 2ed7a04..edd11d9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -2,7 +2,7 @@ import { jwt } from "hono/jwt"; import { validator } from "hono/validator"; import { object, string } from "zod/v4-mini"; import { FlagglyError } from "../error"; -import { inputFeatureFlagSchema } from "@flaggly/engine"; +import { inputFeatureFlagSchema } from "../schema"; import { segmentInputSchema, syncInputSchema, diff --git a/src/routes/api.ts b/src/routes/api.ts index b6be3fa..28c88ed 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -2,7 +2,8 @@ import { jwt } from "hono/jwt"; import { validator } from "hono/validator"; import { omit } from "zod/v4-mini"; -import { evaluateFlag, evaluateInputSchema, requestGeoSchema } from "@flaggly/engine"; +import { evaluateFlag } from "@flaggly/engine"; +import { evaluateInputSchema, requestGeoSchema } from "../schema"; import { FlagglyError } from "../error"; import { paramSchema } from "../schema"; import { createApp } from "./_app"; diff --git a/src/routes/ui.tsx b/src/routes/ui.tsx index f36b249..e1e67de 100644 --- a/src/routes/ui.tsx +++ b/src/routes/ui.tsx @@ -1,7 +1,7 @@ import { html } from "hono/html"; import type { FC } from "hono/jsx"; -import type { AppData, FeatureFlagInputSchema } from "@flaggly/engine"; -import { inputFeatureFlagSchema } from "@flaggly/engine"; +import type { AppData, FeatureFlagInputSchema } from "../schema"; +import { inputFeatureFlagSchema } from "../schema"; import { segmentInputSchema } from "../schema"; import { buildUrl, Layout } from "../components/Layout"; import { createApp } from "./_app"; diff --git a/src/schema.ts b/src/schema.ts index 7922667..5ce5224 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,13 +1,16 @@ import { catch as _catch, _default, + enum as _enum, array, boolean, - enum as _enum, + discriminatedUnion, type infer as Infer, - minLength, - minimum, + literal, maximum, + minimum, + minLength, + nullable, number, object, optional, @@ -17,11 +20,9 @@ import { unknown, } from "zod/v4-mini"; -import { - featureFlagVariationSchema, - flagRule, - rolloutStep, -} from "@flaggly/engine"; +export const flagRule = string().check( + minLength(1, { error: "JEXL rule is required" }), +); export const DEFAULT_ENV = ["production", "staging", "development"]; @@ -57,6 +58,100 @@ export const appSchema = object({ }); export type AppSchema = Infer; +export const featureFlagVariationSchema = object({ + id: string().check(minLength(1, { error: "Variation ID required" })), + label: optional(string()), + weight: number().check(minimum(0), maximum(100)), + payload: optional(unknown()), +}); + +export const rolloutStep = object({ + start: string(), // ISO 8601 date string + percentage: optional(number().check(minimum(0), maximum(100))), + segment: optional(string()), +}).check( + refine( + (step) => step.percentage !== undefined || step.segment !== undefined, + { + error: "Each rollout step must define either percentage or segment", + }, + ), +); +export type RolloutStep = Infer; + +export const baseFeatureFlag = { + id: string().check(minLength(1, { error: "Flag key is required" })), + label: optional(string()), + description: optional(string()), + enabled: _default(optional(boolean()), false), + rules: _default(array(flagRule), []), + rollout: _default(number().check(minimum(0), maximum(100)), 100), + rollouts: _default(array(rolloutStep), []), + isTrackable: _default(boolean(), false), +}; + +export const inputFeatureFlag = { + ...baseFeatureFlag, + segments: _default(array(string()), []), +}; + +export const booleanFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("boolean"), +}); + +export const payloadFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("payload"), + payload: unknown(), +}); + +export const variantFeatureFlag = object({ + ...inputFeatureFlag, + type: literal("variant"), + variations: array(featureFlagVariationSchema).check( + minLength(2, "At least must have 2 variants"), + ), +}); + +export const inputFeatureFlagSchema = discriminatedUnion("type", [ + booleanFeatureFlag, + payloadFeatureFlag, + variantFeatureFlag, +]).check( + refine( + (x) => + !( + x.type === "boolean" && + // @ts-expect-error we want to check for run time input + (x?.variations !== undefined || x?.payload !== undefined) + ), + { + error: "Boolean flags cannot have a payload", + }, + ), + refine((x) => !(x.type === "payload" && x.payload === undefined), { + error: "Payload flags must have a payload", + }), + refine((x) => !(x.type === "variant" && x.variations === undefined), { + error: "Variant flags must have at least 2 variations", + }), +); + +export type FeatureFlagInputSchema = Infer; + +export type AppData = { + flags: Record; + segments: Record; +}; + +export type FeatureFlagOutputSchema = Omit< + FeatureFlagInputSchema, + "segments" +> & { + segments: Record; +}; + export const updateableFeatureFlagSchema = object({ label: optional(string()), description: optional(string()), @@ -69,7 +164,7 @@ export const updateableFeatureFlagSchema = object({ ), rollout: optional(number().check(minimum(0), maximum(100))), rollouts: optional(array(rolloutStep)), - type: optional(_enum(["boolean", "payload", "variant"])), + type: optional(_enum(["boolean", "payload", "variant"])), payload: optional(unknown()), variations: optional( array(featureFlagVariationSchema).check( @@ -131,6 +226,73 @@ export const baseInputSchema = { ), }; +export const requestGeoSchema = _catch( + object({ + country: optional(string()), + + isEUCountry: _catch(optional(boolean()), false), + + continent: optional(string()), + city: optional(string()), + postalCode: optional(string()), + + latitude: optional(string()), + longitude: optional(string()), + timezone: optional(string()), + + region: optional(string()), + regionCode: optional(string()), + metroCode: optional(string()), + }), + { + isEUCountry: false, + }, +); + +export const evaluateInputSchema = object({ + user: optional(unknown()), + id: optional(string()), + request: object({ + headers: record(string(), string()), + }), + page: object({ + url: nullable(string()), + }), + geo: requestGeoSchema, +}); + +export type FlagEvaluationInput = Infer; + +const booleanFlagResult = object({ + type: literal("boolean"), + result: boolean(), + isEval: boolean(), +}); + +const payloadFlagResult = object({ + type: literal("payload"), + result: unknown(), + isEval: boolean(), +}); + +const variantFlagResult = object({ + type: literal("variant"), + result: unknown(), + isEval: boolean(), +}); + +export const evaluateOutputSchema = discriminatedUnion("type", [ + booleanFlagResult, + payloadFlagResult, + variantFlagResult, +]); +export type FlagResultSchema = Infer; + +export const evaluateBatchOutputSchema = record( + string(), + nullable(evaluateOutputSchema), +); + export const paramSchema = object({ id: string(), }); diff --git a/src/storage.ts b/src/storage.ts index b976bf2..d5b72e1 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,5 +1,5 @@ import { FlagglyError, tryPromise } from "./error"; -import type { AppData, FeatureFlagInputSchema } from "@flaggly/engine"; +import type { AppData, FeatureFlagInputSchema } from "./schema"; import type { SegmentInputSchema, SyncInput,