diff --git a/.changeset/vue-integration.md b/.changeset/vue-integration.md
new file mode 100644
index 00000000..1c4301a2
--- /dev/null
+++ b/.changeset/vue-integration.md
@@ -0,0 +1,5 @@
+---
+"@tryabby/vue": minor
+---
+
+Add the Vue integration with typed reactive composables, a request-local provider, React-parity helpers, cookie consent controls, devtools support and Nuxt usage guidance.
diff --git a/apps/docs/pages/integrations/_meta.json b/apps/docs/pages/integrations/_meta.json
index 5cbf2999..aa3900c1 100644
--- a/apps/docs/pages/integrations/_meta.json
+++ b/apps/docs/pages/integrations/_meta.json
@@ -1,6 +1,7 @@
{
"nextjs": "Next.js",
"react": "React",
+ "vue": "Vue",
"remix": "Remix",
"svelte": "Svelte",
"angular": "Angular",
diff --git a/apps/docs/pages/integrations/vue.mdx b/apps/docs/pages/integrations/vue.mdx
new file mode 100644
index 00000000..c319c757
--- /dev/null
+++ b/apps/docs/pages/integrations/vue.mdx
@@ -0,0 +1,46 @@
+# Vue
+
+Use `@tryabby/vue` with Vue 3.3 or later. It builds on Abby core and provides typed, reactive A/B tests, feature flags and remote config.
+
+```sh
+pnpm add @tryabby/vue
+```
+
+```ts
+// abby.ts (client-only app)
+import { createAbby } from "@tryabby/vue";
+
+export const abby = createAbby({
+ projectId: "YOUR_PROJECT_ID",
+ environments: ["production"],
+ currentEnvironment: "production",
+ tests: { checkout: { variants: ["original", "fresh"] } },
+ flags: ["premium"],
+ remoteConfig: { headline: "String" },
+});
+```
+
+Wrap the app with `abby.AbbyProvider`, then call the composables in a descendant's setup function:
+
+```vue
+
+
+
+ {{ headline }}
+ {{ variant }}
+ Premium is enabled
+
+```
+
+Values are readonly computed refs. Vue unwraps them in templates; use `.value` in TypeScript. A/B variants are pending (`undefined`) through SSR and initial hydration, then resolve after mount. Names, variant unions, complete lookup objects and remote config return types are inferred from your configuration.
+
+The provider fetches once on mount unless `initialData` was supplied. It accepts an SSR cookie header through `cookies` and typed targeting properties through `userProperties`. Direct getters, list composables, reset helpers, consent controls and the existing devtools are also supported.
+
+For SSR, create a fresh SDK instance for each request and pass matching initial data to hydration. A Nuxt plugin can create this instance and expose it as `$abby`; do not export a shared SDK singleton from a server module.
+
+See the [Vue package guide](https://github.com/tryabby/abby/tree/main/packages/vue#readme) for the complete API, provider setup, cookie behavior, devtools and a Nuxt plugin example.
diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore
new file mode 100644
index 00000000..1521c8b7
--- /dev/null
+++ b/packages/vue/.gitignore
@@ -0,0 +1 @@
+dist
diff --git a/packages/vue/README.md b/packages/vue/README.md
new file mode 100644
index 00000000..6c0271af
--- /dev/null
+++ b/packages/vue/README.md
@@ -0,0 +1,142 @@
+# @tryabby/vue
+
+Typed Vue 3 composables for Abby A/B tests, feature flags and remote configuration.
+
+```sh
+pnpm add @tryabby/vue
+```
+
+Create an instance with the same configuration used by the other Abby integrations:
+
+```ts
+// abby.ts — a client-only app can export this instance.
+import { createAbby } from "@tryabby/vue";
+
+export const abby = createAbby({
+ projectId: "YOUR_PROJECT_ID",
+ environments: ["production"],
+ currentEnvironment: "production",
+ tests: { checkout: { variants: ["original", "fresh"] } },
+ flags: ["premium"],
+ remoteConfig: { headline: "String", price: "Number", theme: "JSON" },
+ user: { role: { type: "string" } },
+});
+```
+
+Wrap the application in that instance's provider:
+
+```vue
+
+
+
+```
+
+Consume reactive values inside a descendant's `setup()`:
+
+```vue
+
+
+
+ {{ headline }}
+ {{ variant }}
+ Premium feature enabled
+
+```
+
+Composables return readonly computed refs: use `.value` in TypeScript; Vue unwraps them in templates. Names accept a string, ref or getter. Variant values remain `undefined` during SSR and initial hydration, then resolve after the provider mounts and loads data. A view event is sent when the selected test/variant changes; `onAct()` sends a conversion for the current selection. Both use core's telemetry behavior, including its localhost suppression.
+
+`useAbby("checkout", { original: "Buy", fresh: "Try now" })` maps every variant to a typed value. Missing lookup keys and unknown names are TypeScript errors.
+
+## API
+
+| Member | Result / behavior |
+| --- | --- |
+| `useAbby(name, lookup?)` | `{ variant: ComputedRef, onAct }` |
+| `useFeatureFlag(name)` | `ComputedRef` |
+| `useRemoteConfig(name)` | A computed string, number or JSON object, inferred from config |
+| `useFeatureFlags()` / `useRemoteConfigVariables()` | Computed arrays of `{ name, value }` |
+| `getFeatureFlagValue(name)` / `getRemoteConfig(name)` | Nonreactive values outside component setup |
+| `getABTestValue(name, lookup?)` | Current variant or mapped value; retains the assignment within this instance |
+| `getVariants(name)` | The configured readonly variant tuple |
+| `getABResetFunction(name)` | Removes the saved assignment; takes effect on the next page load |
+| `updateUserProperties(properties)` | Validates a partial user update and refreshes targeting |
+| `enableCookies()` / `disableCookies()` | Changes assignment consent; revocation removes assignment cookies |
+| `loadProjectData()` | Explicit refresh, with concurrent fetches shared |
+| `__abby__` | The underlying core instance, also used by devtools |
+| `withDevtools(factory, props)` | Vue component that creates and destroys the existing devtools |
+
+The provider accepts `initialData?: AbbyDataResponse`, `cookies?: string` (an SSR request's cookie header), and typed `userProperties`. With initial data it makes no initial network request. Otherwise it fetches centrally on mount. Configured defaults/fallbacks cover failed requests and absent flags/config variables. Updates to `initialData` or `userProperties` propagate to descendants. Provider teardown removes its subscription; unmounted consumers stop emitting events.
+
+Cookie names and expiry follow core's existing conventions. Assignments stay stable in memory if the browser cannot persist cookies. Set `cookies: { disableByDefault: true }` to start without assignment cookies, then call `enableCookies()` after consent. This setting governs storage; it does not disable core's event transport. `disableCookies()` records the consent preference and removes assignment cookies. Flag/config overrides keep core semantics.
+
+## SSR and Nuxt
+
+**Create a fresh instance per app and per server request.** Do not share a mutable SDK instance in a server module. Reusing one instance across SSR apps raises an error instead of sharing user state. The input config can be shared; each instance copies the mutable test/consent configuration.
+
+Pass the same fetched `initialData` to server rendering and hydration. Feature flags and remote config can render on the server; A/B content remains pending until client mount, avoiding a randomly selected hydration mismatch. Pass the request cookie header only to the server's provider when using existing assignments/overrides. Do not serialize an entire request cookie header into public HTML.
+
+A Nuxt plugin is a suitable place to create the request-local instance:
+
+For the typed server fetch below, also install core as a direct dependency: `pnpm add @tryabby/core`.
+
+```ts
+// plugins/abby.ts
+import { createAbby } from "@tryabby/vue";
+import config from "~/abby.config";
+
+export default defineNuxtPlugin(() => ({
+ provide: { abby: createAbby(config) },
+}));
+```
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+Descendants call `useNuxtApp().$abby.useFeatureFlag(...)`, and so on. This is a Nuxt usage recipe, not an auto-installed Nuxt module.
+
+## Devtools
+
+```ts
+import Devtools from "@tryabby/devtools";
+const AbbyDevtools = abby.withDevtools(Devtools, { defaultShow: true });
+```
+
+Render ` ` once. It initializes on mount in development and cleans up on unmount. `dangerouslyForceShow: true` explicitly enables it in other environments; do not expose it to production users by accident. The devtools package is optional and is not bundled with this SDK.
+
+## Development and local demo
+
+From the repository root, the test command builds core and this package before running the runtime and type checks:
+
+```sh
+pnpm --filter @tryabby/vue test
+pnpm --filter @tryabby/vue demo:build
+node packages/vue/demo/serve.mjs
+```
+
+Open `http://127.0.0.1:8766`. The demo uses the built package with a labelled local fixture, and shows PING/ACT events received by its local server. It does not contact the hosted Abby backend. Use “Target admin user” before manually toggling the flag, since a manual override takes precedence over targeting.
diff --git a/packages/vue/demo/.gitignore b/packages/vue/demo/.gitignore
new file mode 100644
index 00000000..567609b1
--- /dev/null
+++ b/packages/vue/demo/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/packages/vue/demo/index.html b/packages/vue/demo/index.html
new file mode 100644
index 00000000..8e0ef12a
--- /dev/null
+++ b/packages/vue/demo/index.html
@@ -0,0 +1,5 @@
+
+
+ Abby Vue integration demo
+
+
diff --git a/packages/vue/demo/main.ts b/packages/vue/demo/main.ts
new file mode 100644
index 00000000..9daceb02
--- /dev/null
+++ b/packages/vue/demo/main.ts
@@ -0,0 +1,154 @@
+import type { AbbyDataResponse } from "@tryabby/core";
+import { createAbby } from "@tryabby/vue";
+import {
+ createApp,
+ defineComponent,
+ h,
+ onMounted,
+ onUnmounted,
+ ref,
+} from "vue";
+import "./style.css";
+
+const fixture: AbbyDataResponse = {
+ tests: [{ name: "checkout", weights: [1, 0] }],
+ flags: [
+ {
+ name: "premium",
+ value: false,
+ ruleSet: [
+ {
+ propertyName: "role",
+ propertyType: "string",
+ operator: "eq",
+ value: "admin",
+ thenValue: true,
+ },
+ ],
+ },
+ ],
+ remoteConfig: [
+ { name: "headline", value: "A small change. A clear result." },
+ ],
+};
+const sdk = createAbby({
+ projectId: "vue-local-demo",
+ currentEnvironment: "demo",
+ environments: ["demo"],
+ apiUrl: "/",
+ tests: { checkout: { variants: ["original", "fresh"] } },
+ flags: ["premium"],
+ remoteConfig: { headline: "String" },
+ user: { role: { type: "string" } },
+});
+const Demo = defineComponent({
+ setup() {
+ const { variant, onAct } = sdk.useAbby("checkout");
+ const premium = sdk.useFeatureFlag("premium");
+ const headline = sdk.useRemoteConfig("headline");
+ const views = ref(0);
+ const acts = ref(0);
+ const targeting = ref("visitor");
+ const consent = ref(!sdk.__abby__.getConfig().cookies?.disableByDefault);
+ let timer: ReturnType;
+ onMounted(() => {
+ const read = async () => {
+ const events = (await fetch("/events").then((response) =>
+ response.json()
+ )) as { type: number }[];
+ views.value = events.filter((event) => event.type === 0).length;
+ acts.value = events.filter((event) => event.type === 1).length;
+ };
+ void read();
+ timer = setInterval(() => {
+ void read();
+ }, 300);
+ });
+ onUnmounted(() => clearInterval(timer));
+ const button = (text: string, action: () => void) =>
+ h("button", { type: "button", onClick: action }, text);
+ return () =>
+ h("main", [
+ h("p", { class: "eyebrow" }, "ABBY / VUE 3"),
+ h("h1", "Vue integration, live."),
+ h(
+ "p",
+ { class: "intro" },
+ "A/B testing, feature flags and remote config — reacting together."
+ ),
+ h(
+ "p",
+ { class: "fixture" },
+ "Local deterministic fixture · actual built SDK · no hosted Abby connection"
+ ),
+ h("section", { class: "preview", "aria-label": "Live preview" }, [
+ h("div", { class: "topline" }, [
+ h("span", "CHECKOUT PREVIEW"),
+ h(
+ "span",
+ { class: "pill" },
+ `Variant: ${variant.value ?? "pending"}`
+ ),
+ ]),
+ h("h2", headline.value),
+ h(
+ "p",
+ `Premium feature: ${premium.value ? "enabled" : "disabled"} · User role: ${targeting.value}`
+ ),
+ h(
+ "button",
+ { type: "button", class: `cta ${variant.value}`, onClick: onAct },
+ "Record conversion"
+ ),
+ ]),
+ h("section", { class: "controls", "aria-label": "SDK controls" }, [
+ button("Switch variant", () =>
+ sdk.__abby__.updateLocalVariant(
+ "checkout",
+ variant.value === "original" ? "fresh" : "original"
+ )
+ ),
+ button("Toggle feature flag", () =>
+ sdk.__abby__.updateFlag("premium", !premium.value)
+ ),
+ button("Change remote title", () =>
+ sdk.__abby__.updateRemoteConfig(
+ "headline",
+ "Updated remotely. Rendered immediately."
+ )
+ ),
+ button("Target admin user", () => {
+ sdk.updateUserProperties({ role: "admin" });
+ targeting.value = "admin";
+ }),
+ button(
+ consent.value ? "Revoke cookie consent" : "Enable cookie consent",
+ () => {
+ consent.value = !consent.value;
+ if (consent.value) sdk.enableCookies();
+ else sdk.disableCookies();
+ }
+ ),
+ ]),
+ h("footer", [
+ h("div", [
+ h("strong", String(views.value)),
+ h("span", "View events"),
+ ]),
+ h("div", [
+ h("strong", String(acts.value)),
+ h("span", "Conversion events"),
+ ]),
+ h("div", [
+ h("strong", consent.value ? "On" : "Off"),
+ h("span", "Assignment cookies"),
+ ]),
+ h("p", "Events shown here were received by this local demo server."),
+ ]),
+ ]);
+ },
+});
+createApp({
+ render: () =>
+ h(sdk.AbbyProvider, { initialData: fixture }, { default: () => h(Demo) }),
+}).mount("#app");
diff --git a/packages/vue/demo/serve.mjs b/packages/vue/demo/serve.mjs
new file mode 100644
index 00000000..095bb878
--- /dev/null
+++ b/packages/vue/demo/serve.mjs
@@ -0,0 +1,78 @@
+import { readFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(fileURLToPath(new URL("./build/", import.meta.url)));
+const events = [];
+const port = Number(process.env.PORT ?? 8766);
+const server = createServer(async (request, response) => {
+ const url = new URL(request.url, `http://127.0.0.1:${port}`);
+ if (request.method === "POST" && url.pathname === "/api/data") {
+ let body = "";
+ for await (const chunk of request) {
+ body += chunk;
+ if (body.length > 16384) {
+ response.writeHead(413).end();
+ return;
+ }
+ }
+ try {
+ const event = JSON.parse(body);
+ if (event.projectId !== "vue-local-demo" || ![0, 1].includes(event.type))
+ throw new Error("Invalid event");
+ events.push(event);
+ response
+ .writeHead(200, { "Content-Type": "application/json" })
+ .end('{"ok":true}');
+ } catch {
+ response.writeHead(400).end();
+ }
+ return;
+ }
+ if (url.pathname === "/events") {
+ response
+ .writeHead(200, {
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store",
+ })
+ .end(JSON.stringify(events));
+ return;
+ }
+ let pathname;
+ try {
+ pathname = decodeURIComponent(
+ url.pathname === "/" ? "/index.html" : url.pathname
+ );
+ } catch {
+ response.writeHead(400).end();
+ return;
+ }
+ const filename = path.resolve(root, `.${pathname}`);
+ if (
+ !filename.startsWith(`${root}${path.sep}`) &&
+ filename !== path.join(root, "index.html")
+ ) {
+ response.writeHead(403).end();
+ return;
+ }
+ try {
+ const content = await readFile(filename);
+ const types = {
+ ".html": "text/html",
+ ".js": "text/javascript",
+ ".css": "text/css",
+ };
+ response
+ .writeHead(200, {
+ "Content-Type":
+ types[path.extname(filename)] ?? "application/octet-stream",
+ })
+ .end(content);
+ } catch {
+ response.writeHead(404).end();
+ }
+});
+server.listen(port, "127.0.0.1", () =>
+ console.log(`Abby Vue local fixture: http://127.0.0.1:${port}`)
+);
diff --git a/packages/vue/demo/style.css b/packages/vue/demo/style.css
new file mode 100644
index 00000000..2db7e0b0
--- /dev/null
+++ b/packages/vue/demo/style.css
@@ -0,0 +1,24 @@
+:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #182c3a; background: #f1f5f5; font-synthesis: none; }
+* { box-sizing: border-box; }
+body { margin: 0; }
+main { max-width: 1040px; margin: 0 auto; padding: 42px 48px; }
+.eyebrow { color: #16775e; letter-spacing: .18em; font-size: 12px; font-weight: 800; }
+h1 { font-size: 44px; letter-spacing: -.04em; margin: 14px 0 8px; }
+.intro { font-size: 18px; color: #536874; margin-bottom: 12px; }
+.fixture { display: inline-block; font-size: 12px; background: #e0ece7; color: #315849; padding: 8px 12px; border-radius: 5px; }
+.preview { margin-top: 22px; background: #182f3b; color: white; border-radius: 16px; padding: 28px 32px; }
+.topline { display: flex; justify-content: space-between; align-items: center; font-size: 11px; letter-spacing: .1em; color: #a3c4cb; }
+.pill { letter-spacing: normal; border: 1px solid #73989c; border-radius: 20px; padding: 6px 12px; color: white; }
+h2 { font-size: 29px; letter-spacing: -.025em; margin: 24px 0 8px; }
+.preview p { color: #c4d7dc; }
+button { cursor: pointer; font: inherit; font-size: 13px; font-weight: 650; padding: 12px 16px; border: 1px solid #c7d3d5; border-radius: 8px; background: white; color: #25424d; }
+button:hover { border-color: #16775e; background: #e9f4ee; }
+button:focus-visible { outline: 3px solid #d4a531; outline-offset: 3px; }
+.cta { margin-top: 12px; color: #183e31; background: #c6edb9; border: none; }
+.cta.fresh { background: #a4d9f1; color: #143e64; }
+.controls { display: flex; flex-wrap: wrap; gap: 9px; margin: 24px 0; }
+footer { display: flex; gap: 36px; align-items: center; border-top: 1px solid #cedadb; padding-top: 20px; }
+footer strong { display: block; font-size: 27px; }
+footer span { font-size: 12px; color: #536874; }
+footer p { max-width: 225px; margin-left: auto; font-size: 12px; line-height: 1.5; color: #536874; }
+@media (max-width: 650px) { main { padding: 24px; } h1 { font-size: 34px; } footer { flex-wrap: wrap; gap: 20px; } footer p { margin-left: 0; } }
diff --git a/packages/vue/demo/vite.config.ts b/packages/vue/demo/vite.config.ts
new file mode 100644
index 00000000..586be58b
--- /dev/null
+++ b/packages/vue/demo/vite.config.ts
@@ -0,0 +1,10 @@
+import path from "node:path";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ root: path.resolve(__dirname),
+ resolve: {
+ alias: { "@tryabby/vue": path.resolve(__dirname, "../dist/index.mjs") },
+ },
+ build: { outDir: "build", emptyOutDir: true },
+});
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 00000000..fa2e7859
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@tryabby/vue",
+ "version": "0.1.0",
+ "description": "Typed Vue composables for Abby feature flags, A/B tests and remote config",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
+ "files": ["dist"],
+ "sideEffects": false,
+ "scripts": {
+ "build": "tsup src/index.ts",
+ "dev": "pnpm run build --watch",
+ "test": "pnpm --filter @tryabby/core build && pnpm run build && vitest run && tsc --noEmit -p tsconfig.test.json",
+ "demo:build": "vite build --config demo/vite.config.ts"
+ },
+ "homepage": "https://docs.tryabby.com",
+ "license": "ISC",
+ "dependencies": {
+ "@tryabby/core": "workspace:*",
+ "js-cookie": "^3.0.1"
+ },
+ "peerDependencies": { "vue": "^3.3.0" },
+ "devDependencies": {
+ "@types/js-cookie": "^3.0.3",
+ "@types/node": "^22.5.1",
+ "@vue/server-renderer": "3.5.13",
+ "@vue/test-utils": "2.4.6",
+ "jsdom": "^20.0.3",
+ "tsconfig": "workspace:*",
+ "tsup": "^6.5.0",
+ "typescript": "5.5.4",
+ "vite": "5.4.0",
+ "vitest": "2.0.5",
+ "vue": "3.5.13"
+ }
+}
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
new file mode 100644
index 00000000..5b571d17
--- /dev/null
+++ b/packages/vue/src/index.ts
@@ -0,0 +1,475 @@
+import {
+ type ABConfig,
+ Abby,
+ type AbbyConfig,
+ type AbbyDataResponse,
+ AbbyEventType,
+ HttpService,
+ type RemoteConfigValueString,
+ type RemoteConfigValueStringToType,
+ type ValidatorType,
+ getABStorageKey,
+ getDefaultRemoteConfigValue,
+ getFFStorageKey,
+ getRCStorageKey,
+} from "@tryabby/core";
+import type { Infer } from "@tryabby/core/validation";
+import Cookie from "js-cookie";
+import {
+ type ComputedRef,
+ type InjectionKey,
+ type MaybeRefOrGetter,
+ computed,
+ defineComponent,
+ getCurrentInstance,
+ inject,
+ onMounted,
+ onUnmounted,
+ provide,
+ shallowRef,
+ toValue,
+ watch,
+} from "vue";
+
+export { defineConfig, type ABConfig, type AbbyConfig } from "@tryabby/core";
+
+export type ABTestReturnValue = Lookup extends undefined
+ ? Variant
+ : Variant extends keyof Lookup
+ ? Lookup[Variant]
+ : never;
+
+/** Create one instance per Vue app, and a fresh instance for each SSR request. */
+export function createAbby<
+ const FlagName extends string,
+ const Tests extends Record,
+ const RemoteConfig extends Record,
+ const RemoteConfigName extends Extract,
+ const User extends Record = Record<
+ string,
+ ValidatorType
+ >,
+>(
+ input: AbbyConfig<
+ FlagName,
+ Tests,
+ string[],
+ RemoteConfigName,
+ RemoteConfig,
+ User
+ >
+) {
+ // Core mutates tests and cookie consent. Never mutate a shared config object.
+ const config = {
+ ...input,
+ cookies: { ...input.cookies },
+ tests: Object.fromEntries(
+ Object.entries(input.tests ?? {}).map(([name, test]) => {
+ if (!test.variants.length)
+ throw new Error(`Abby test "${name}" needs a variant`);
+ return [name, { ...test, variants: [...test.variants] }];
+ })
+ ) as unknown as Tests,
+ };
+ type UserProperties = Partial<{
+ -readonly [K in keyof User]: Infer;
+ }>;
+ const revision = shallowRef(0);
+ const mounted = shallowRef(false);
+ const ready = shallowRef(false);
+ const assignments = new Map();
+ let cookieHeader: string | undefined;
+ let selectionReady = false;
+ let initialized = false;
+ let dataVersion = 0;
+ let owner: object | undefined;
+ let loading: Promise | undefined;
+ const consentKey = getABStorageKey(config.projectId, "$_abcc_$");
+
+ const readCookie = (key: string) => {
+ if (cookieHeader === undefined)
+ return typeof document === "undefined" ? undefined : Cookie.get(key);
+ for (const entry of cookieHeader.split(";")) {
+ const separator = entry.indexOf("=");
+ if (separator < 0) continue;
+ try {
+ if (decodeURIComponent(entry.slice(0, separator).trim()) === key)
+ return decodeURIComponent(entry.slice(separator + 1));
+ } catch {
+ /* Ignore malformed cookie encoding. */
+ }
+ }
+ return undefined;
+ };
+ const writeCookie = (key: string, value: string) => {
+ if (!mounted.value || config.cookies.disableByDefault) return;
+ Cookie.set(key, value, { expires: config.cookies.expiresInDays ?? 365 });
+ };
+ const storage = (key: (projectId: string, name: string) => string) => ({
+ get: (name: string) => readCookie(key(config.projectId, name)) ?? null,
+ set: (name: string, value: string) =>
+ writeCookie(key(config.projectId, name), value),
+ });
+ const abby = new Abby<
+ FlagName,
+ Extract,
+ Tests,
+ RemoteConfig,
+ RemoteConfigName,
+ string[],
+ User
+ >(
+ config,
+ {
+ get: (name) => {
+ if (config.cookies.disableByDefault) return null;
+ const value =
+ assignments.get(name) ??
+ readCookie(getABStorageKey(config.projectId, name));
+ return config.tests[name]?.variants.includes(value ?? "")
+ ? (value ?? null)
+ : null;
+ },
+ set: (name, value) => {
+ if (name === "$_abcc_$") {
+ writeCookie(consentKey, value);
+ return;
+ }
+ if (!selectionReady) return;
+ assignments.set(name, value);
+ writeCookie(getABStorageKey(config.projectId, name), value);
+ },
+ },
+ storage(getFFStorageKey),
+ storage(getRCStorageKey)
+ );
+
+ const normalize = (data: AbbyDataResponse): AbbyDataResponse => ({
+ tests: data.tests,
+ flags: (config.flags ?? []).map(
+ (name) =>
+ data.flags.find((flag) => flag.name === name) ?? {
+ name,
+ value:
+ config.settings?.flags?.fallbackValues?.[name] ??
+ config.settings?.flags?.defaultValue ??
+ false,
+ }
+ ),
+ remoteConfig: (
+ Object.entries(config.remoteConfig ?? {}) as Array<
+ [RemoteConfigName, RemoteConfigValueString]
+ >
+ ).map(
+ ([name, type]) =>
+ data.remoteConfig?.find((item) => item.name === name) ?? {
+ name,
+ value:
+ config.settings?.remoteConfig?.fallbackValues?.[
+ name as RemoteConfigName
+ ] ??
+ config.settings?.remoteConfig?.defaultValues?.[type] ??
+ getDefaultRemoteConfigValue(type),
+ }
+ ),
+ });
+ const initialize = (data: AbbyDataResponse) => {
+ dataVersion++;
+ abby.init(normalize(data));
+ if (cookieHeader !== undefined) abby.setLocalOverrides(cookieHeader);
+ initialized = true;
+ revision.value++;
+ };
+ const ensureInitialized = () => {
+ if (!initialized) initialize({ tests: [], flags: [], remoteConfig: [] });
+ };
+ const loadProjectData = () => {
+ if (!loading) {
+ const version = dataVersion;
+ loading = (async () => {
+ const data = await HttpService.getProjectData({
+ projectId: config.projectId,
+ environment: config.currentEnvironment,
+ url: config.apiUrl,
+ fetch: config.fetch,
+ __experimentalCdnUrl: config.__experimentalCdnUrl
+ ? `${config.__experimentalCdnUrl}/${config.projectId}/${config.currentEnvironment}`
+ : undefined,
+ });
+ if (version === dataVersion) {
+ if (data) initialize(data);
+ else ensureInitialized();
+ }
+ })().finally(() => {
+ loading = undefined;
+ });
+ }
+ return loading;
+ };
+
+ const key: InjectionKey = Symbol("Abby");
+ const requireProvider = () => {
+ if (!getCurrentInstance() || !inject(key, undefined))
+ throw new Error(
+ "Abby composables must be used in setup() inside this instance's AbbyProvider"
+ );
+ };
+ const AbbyProvider = defineComponent(
+ (
+ props: {
+ initialData?: AbbyDataResponse;
+ cookies?: string;
+ userProperties?: UserProperties;
+ },
+ { slots }
+ ) => {
+ const app = getCurrentInstance()?.appContext;
+ if (owner && owner !== app)
+ throw new Error(
+ "Create a fresh Abby instance for each Vue app or SSR request"
+ );
+ owner = app;
+ cookieHeader = props.cookies;
+ const consent = readCookie(consentKey);
+ if (consent !== undefined)
+ config.cookies.disableByDefault = consent !== "true";
+ if (props.userProperties) abby.updateUserProperties(props.userProperties);
+ if (props.initialData) initialize(props.initialData);
+ else ensureInitialized();
+ provide(key, revision);
+ let unsubscribe: (() => void) | undefined;
+ let alive = true;
+ onMounted(async () => {
+ mounted.value = true;
+ unsubscribe = abby.subscribe(() => {
+ revision.value++;
+ });
+ if (!props.initialData) await loadProjectData();
+ if (!alive) return;
+ selectionReady = true;
+ ready.value = true;
+ revision.value++;
+ });
+ watch(
+ () => props.initialData,
+ (data) => {
+ if (data) initialize(data);
+ }
+ );
+ watch(
+ () => props.userProperties,
+ (user) => {
+ if (user) abby.updateUserProperties(user);
+ },
+ { deep: true }
+ );
+ onUnmounted(() => {
+ alive = false;
+ unsubscribe?.();
+ mounted.value = false;
+ ready.value = false;
+ owner = undefined;
+ });
+ return () => slots.default?.();
+ },
+ {
+ name: "AbbyProvider",
+ props: ["initialData", "cookies", "userProperties"],
+ }
+ );
+
+ const assertName = (name: string, names: readonly string[], kind: string) => {
+ if (!names.includes(name)) throw new Error(`Unknown Abby ${kind}: ${name}`);
+ };
+ const getFeatureFlagValue = (name: FlagName): boolean => {
+ assertName(name, config.flags ?? [], "feature flag");
+ ensureInitialized();
+ return abby.getFeatureFlag(name);
+ };
+ const getRemoteConfig = (
+ name: K
+ ): RemoteConfigValueStringToType => {
+ assertName(name, Object.keys(config.remoteConfig ?? {}), "remote config");
+ ensureInitialized();
+ return abby.getRemoteConfig(name);
+ };
+ const getVariants = (
+ name: K
+ ): Tests[K]["variants"] => {
+ assertName(String(name), Object.keys(config.tests), "test");
+ return abby.getVariants(name);
+ };
+ const getABTestValue = <
+ K extends keyof Tests,
+ const Lookup extends
+ | Record
+ | undefined = undefined,
+ >(
+ name: K,
+ lookup?: Lookup
+ ): ABTestReturnValue => {
+ getVariants(name);
+ ensureInitialized();
+ selectionReady = true;
+ const variant = abby.getTestVariant(name);
+ return (lookup ? lookup[variant] : variant) as ABTestReturnValue<
+ Lookup,
+ Tests[K]["variants"][number]
+ >;
+ };
+ const useFeatureFlag = (
+ name: MaybeRefOrGetter
+ ): ComputedRef => {
+ requireProvider();
+ return computed(() => {
+ revision.value;
+ return getFeatureFlagValue(toValue(name));
+ });
+ };
+ const useRemoteConfig = (
+ name: MaybeRefOrGetter
+ ): ComputedRef> => {
+ requireProvider();
+ return computed(() => {
+ revision.value;
+ return getRemoteConfig(toValue(name));
+ });
+ };
+ const useFeatureFlags = () => {
+ requireProvider();
+ return computed(() => {
+ revision.value;
+ return (config.flags ?? []).map((name) => ({
+ name,
+ value: getFeatureFlagValue(name),
+ }));
+ });
+ };
+ const useRemoteConfigVariables = () => {
+ requireProvider();
+ return computed(() => {
+ revision.value;
+ return (Object.keys(config.remoteConfig ?? {}) as RemoteConfigName[]).map(
+ (name) => ({ name, value: getRemoteConfig(name) })
+ );
+ });
+ };
+ const useAbby = <
+ K extends keyof Tests,
+ const Lookup extends
+ | Record
+ | undefined = undefined,
+ >(
+ name: MaybeRefOrGetter,
+ lookup?: Lookup
+ ): {
+ variant: ComputedRef<
+ ABTestReturnValue | undefined
+ >;
+ onAct: () => void;
+ } => {
+ requireProvider();
+ const selected = computed(() => {
+ revision.value;
+ getVariants(toValue(name));
+ return mounted.value && ready.value
+ ? getABTestValue(toValue(name))
+ : undefined;
+ });
+ const send = (type: AbbyEventType) => {
+ if (!mounted.value || !selected.value) return;
+ HttpService.sendData({
+ url: config.apiUrl,
+ type,
+ data: {
+ projectId: config.projectId,
+ testName: String(toValue(name)),
+ selectedVariant: selected.value,
+ },
+ });
+ };
+ watch([() => toValue(name), selected], () => send(AbbyEventType.PING), {
+ flush: "post",
+ });
+ return {
+ variant: computed(() =>
+ selected.value === undefined
+ ? undefined
+ : ((lookup
+ ? lookup[selected.value]
+ : selected.value) as ABTestReturnValue<
+ Lookup,
+ Tests[K]["variants"][number]
+ >)
+ ),
+ onAct: () => send(AbbyEventType.ACT),
+ };
+ };
+ const getABResetFunction = (name: K) => {
+ getVariants(name);
+ return () => {
+ assignments.delete(String(name));
+ if (typeof document !== "undefined")
+ Cookie.remove(getABStorageKey(config.projectId, String(name)));
+ // Like React, reset takes effect on the next page load.
+ };
+ };
+ const updateUserProperties = (user: UserProperties) =>
+ abby.updateUserProperties(user);
+ const enableCookies = () => {
+ abby.enableCookies();
+ revision.value++;
+ };
+ const disableCookies = () => {
+ abby.disableCookies();
+ assignments.clear();
+ if (typeof document !== "undefined") {
+ Cookie.set(consentKey, "false", {
+ expires: config.cookies.expiresInDays ?? 365,
+ });
+ for (const name of Object.keys(config.tests))
+ Cookie.remove(getABStorageKey(config.projectId, name));
+ }
+ revision.value++;
+ };
+ const withDevtools = (
+ factory: { create(props: Props): () => void },
+ props: Omit & { dangerouslyForceShow?: boolean }
+ ) =>
+ defineComponent({
+ name: "AbbyDevtools",
+ setup() {
+ let destroy: (() => void) | undefined;
+ onMounted(() => {
+ if (
+ !props.dangerouslyForceShow &&
+ process.env.NODE_ENV !== "development"
+ )
+ return;
+ const { dangerouslyForceShow: _force, ...options } = props;
+ destroy = factory.create({ ...options, abby } as Props);
+ });
+ onUnmounted(() => destroy?.());
+ return () => null;
+ },
+ });
+ return {
+ AbbyProvider,
+ useAbby,
+ useFeatureFlag,
+ useRemoteConfig,
+ useFeatureFlags,
+ useRemoteConfigVariables,
+ getFeatureFlagValue,
+ getRemoteConfig,
+ getABTestValue,
+ getVariants,
+ getABResetFunction,
+ updateUserProperties,
+ withDevtools,
+ enableCookies,
+ disableCookies,
+ loadProjectData,
+ __abby__: abby,
+ };
+}
diff --git a/packages/vue/tests/consumer.ts b/packages/vue/tests/consumer.ts
new file mode 100644
index 00000000..9699c3ad
--- /dev/null
+++ b/packages/vue/tests/consumer.ts
@@ -0,0 +1,25 @@
+import { createAbby, defineConfig } from "@tryabby/vue";
+import type { ComputedRef } from "vue";
+
+// These checks resolve the published declaration entry, not src/index.ts.
+export function declarationConsumer() {
+ const sdk = createAbby(
+ defineConfig(
+ { projectId: "consumer", currentEnvironment: "test" },
+ {
+ environments: ["test"],
+ tests: { checkout: { variants: ["a", "b"] } },
+ flags: ["premium"],
+ remoteConfig: { price: "Number" },
+ }
+ )
+ );
+ const price: ComputedRef = sdk.useRemoteConfig("price");
+ const variant: ComputedRef<"a" | "b" | undefined> =
+ sdk.useAbby("checkout").variant;
+ // @ts-expect-error Published declarations must reject unknown flags.
+ sdk.useFeatureFlag("typo");
+ // @ts-expect-error Published declarations must reject incomplete lookups.
+ sdk.getABTestValue("checkout", { a: 1 });
+ return { price, variant };
+}
diff --git a/packages/vue/tests/demo-server.test.ts b/packages/vue/tests/demo-server.test.ts
new file mode 100644
index 00000000..26c09d25
--- /dev/null
+++ b/packages/vue/tests/demo-server.test.ts
@@ -0,0 +1,82 @@
+// @vitest-environment node
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { get } from "node:http";
+import { type AddressInfo, createServer } from "node:net";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { expect, it } from "vitest";
+
+it("returns 400 for malformed URL encoding and keeps serving requests", async () => {
+ const temporaryRoot = path.resolve(tmpdir());
+ const directory = await mkdtemp(path.join(temporaryRoot, "abby-vue-demo-"));
+ // Only this test's direct temporary child may be removed during cleanup.
+ if (path.dirname(directory) !== temporaryRoot)
+ throw new Error("Unexpected temporary path");
+ const listener = createServer();
+ listener.listen(0, "127.0.0.1");
+ await once(listener, "listening");
+ const port = (listener.address() as AddressInfo).port;
+ await new Promise((resolve) => listener.close(() => resolve()));
+ await mkdir(path.join(directory, "build"));
+ await writeFile(
+ path.join(directory, "build/index.html"),
+ "Demo is available"
+ );
+ await copyFile(
+ new URL("../demo/serve.mjs", import.meta.url),
+ path.join(directory, "serve.mjs")
+ );
+ const child = spawn(process.execPath, [path.join(directory, "serve.mjs")], {
+ env: { ...process.env, PORT: String(port) },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ const exited = once(child, "exit");
+ let stderr = "";
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ const request = (pathname: string) =>
+ new Promise<{ status: number | undefined; body: string }>(
+ (resolve, reject) => {
+ const request = get(
+ { hostname: "127.0.0.1", port, path: pathname },
+ (response) => {
+ let body = "";
+ response.setEncoding("utf8");
+ response.on("data", (chunk) => {
+ body += chunk;
+ });
+ response.on("end", () =>
+ resolve({ status: response.statusCode, body })
+ );
+ }
+ );
+ request.on("error", reject);
+ request.setTimeout(2000, () =>
+ request.destroy(new Error("Request timed out"))
+ );
+ }
+ );
+ try {
+ await Promise.race([
+ once(child.stdout, "data"),
+ exited.then(() => {
+ throw new Error(`Demo server exited before listening: ${stderr}`);
+ }),
+ ]);
+ expect((await request("/%ZZ")).status).toBe(400);
+ expect(await request("/")).toEqual({
+ status: 200,
+ body: "Demo is available",
+ });
+ expect(child.exitCode).toBeNull();
+ expect(stderr).toBe("");
+ } finally {
+ if (child.exitCode === null) child.kill();
+ await exited;
+ await rm(directory, { recursive: true, force: true });
+ if (stderr) console.error(stderr);
+ }
+});
diff --git a/packages/vue/tests/fixtures.ts b/packages/vue/tests/fixtures.ts
new file mode 100644
index 00000000..f57e7b1a
--- /dev/null
+++ b/packages/vue/tests/fixtures.ts
@@ -0,0 +1,39 @@
+import type { AbbyDataResponse } from "@tryabby/core";
+import * as v from "@tryabby/core/validation";
+import { createAbby } from "../src";
+
+export const config = {
+ projectId: "vue-test",
+ environments: ["test"],
+ currentEnvironment: "test",
+ tests: {
+ button: { variants: ["control", "blue"] },
+ other: { variants: ["one"] },
+ },
+ flags: ["premium", "search"],
+ remoteConfig: { title: "String", count: "Number", theme: "JSON" },
+ user: { role: v.string(), age: v.number(), active: v.optional(v.boolean()) },
+} as const;
+
+export const data: AbbyDataResponse = {
+ tests: [
+ { name: "button", weights: [0, 1] },
+ { name: "other", weights: [1] },
+ ],
+ flags: [
+ { name: "premium", value: true },
+ { name: "search", value: false },
+ ],
+ remoteConfig: [
+ { name: "title", value: "Hello Vue" },
+ { name: "count", value: 7 },
+ { name: "theme", value: { dark: true } },
+ ],
+};
+
+export const makeAbby = () =>
+ createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ });
diff --git a/packages/vue/tests/package.test.ts b/packages/vue/tests/package.test.ts
new file mode 100644
index 00000000..330ac5c8
--- /dev/null
+++ b/packages/vue/tests/package.test.ts
@@ -0,0 +1,27 @@
+// @vitest-environment node
+import { execFileSync } from "node:child_process";
+import { expect, it } from "vitest";
+
+it.each(["module", "commonjs"])(
+ "loads the built %s package entry",
+ (format) => {
+ const load =
+ format === "module"
+ ? 'import { createAbby } from "@tryabby/vue";'
+ : 'const { createAbby } = require("@tryabby/vue");';
+ const result = execFileSync(
+ process.execPath,
+ [
+ "--input-type",
+ format,
+ "-e",
+ `${load}
+ const sdk = createAbby({projectId:'consumer',currentEnvironment:'test',environments:['test'],remoteConfig:{price:'Number'}});
+ console.log(JSON.stringify({value:sdk.getRemoteConfig('price'), variants:typeof sdk.getVariants}));
+ `,
+ ],
+ { cwd: process.cwd(), encoding: "utf8" }
+ );
+ expect(JSON.parse(result)).toEqual({ value: 0, variants: "function" });
+ }
+);
diff --git a/packages/vue/tests/runtime.test.ts b/packages/vue/tests/runtime.test.ts
new file mode 100644
index 00000000..cae47f27
--- /dev/null
+++ b/packages/vue/tests/runtime.test.ts
@@ -0,0 +1,456 @@
+import {
+ type AbbyDataResponse,
+ AbbyEventType,
+ HttpService,
+ getABStorageKey,
+} from "@tryabby/core";
+import { renderToString } from "@vue/server-renderer";
+import { type VueWrapper, flushPromises, mount } from "@vue/test-utils";
+import Cookie from "js-cookie";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createSSRApp, defineComponent, h, nextTick, ref } from "vue";
+import { createAbby } from "../src";
+import { config, data, makeAbby } from "./fixtures";
+
+const wrappers: VueWrapper[] = [];
+beforeEach(() => {
+ // All HTTP is deterministic and local to the test, including core telemetry.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("{}", { status: 200 }))
+ );
+});
+afterEach(() => {
+ for (const wrapper of wrappers.splice(0)) wrapper.unmount();
+ for (const key of Object.keys(Cookie.get())) Cookie.remove(key);
+ vi.unstubAllGlobals();
+});
+
+function render(
+ sdk: ReturnType,
+ setup: () => () => ReturnType,
+ initialData: AbbyDataResponse | null = data
+) {
+ const Child = defineComponent({ setup });
+ const wrapper = mount(sdk.AbbyProvider, {
+ props: { initialData: initialData ?? undefined },
+ slots: { default: () => h(Child) },
+ });
+ wrappers.push(wrapper);
+ return wrapper;
+}
+
+describe("Vue integration with real core", () => {
+ it("does not overwrite newer provider data with a late initial fetch", async () => {
+ let resolveFetch: (response: Response) => void = () => {};
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ fetch: () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ });
+ const wrapper = render(
+ sdk,
+ () => {
+ const title = sdk.useRemoteConfig("title");
+ return () => h("div", title.value);
+ },
+ null
+ );
+ await wrapper.setProps({
+ initialData: {
+ ...data,
+ remoteConfig: [{ name: "title", value: "New provider data" }],
+ },
+ });
+ resolveFetch(new Response(JSON.stringify(data), { status: 200 }));
+ await flushPromises();
+ expect(wrapper.text()).toBe("New provider data");
+ });
+
+ it("does not emit events or recreate subscriptions when a fetch completes after unmount", async () => {
+ let resolveFetch: (response: Response) => void = () => {};
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ fetch: () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ });
+ const send = vi
+ .spyOn(HttpService, "sendData")
+ .mockImplementation(() => undefined);
+ const wrapper = render(
+ sdk,
+ () => {
+ const { variant } = sdk.useAbby("button");
+ return () => h("div", variant.value);
+ },
+ null
+ );
+ wrapper.unmount();
+ resolveFetch(new Response(JSON.stringify(data), { status: 200 }));
+ await flushPromises();
+ expect(send).not.toHaveBeenCalled();
+ expect(
+ Cookie.get(getABStorageKey(config.projectId, "button"))
+ ).toBeUndefined();
+ });
+
+ it("sends real core PING/ACT payloads for reactive test names without duplicate views", async () => {
+ const sdk = makeAbby();
+ const name = ref<"button" | "other">("button");
+ let act = () => {};
+ render(sdk, () => {
+ const result = sdk.useAbby(name);
+ act = result.onAct;
+ return () => h("div", result.variant.value);
+ });
+ await flushPromises();
+ const fetcher = vi.mocked(globalThis.fetch);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
+ type: 0,
+ projectId: "vue-test",
+ testName: "button",
+ selectedVariant: "blue",
+ });
+ sdk.__abby__.updateFlag("premium", false);
+ await nextTick();
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ name.value = "other";
+ await nextTick();
+ act();
+ expect(JSON.parse(String(fetcher.mock.calls.at(-1)?.[1]?.body))).toEqual({
+ type: 1,
+ projectId: "vue-test",
+ testName: "other",
+ selectedVariant: "one",
+ });
+ });
+ it("renders weighted variants, flags, remote configs and typed lookups reactively", async () => {
+ const sdk = makeAbby();
+ const send = vi
+ .spyOn(HttpService, "sendData")
+ .mockImplementation(() => undefined);
+ let act = () => {};
+ const wrapper = render(sdk, () => {
+ const { variant, onAct } = sdk.useAbby("button", {
+ control: "Original",
+ blue: "Blue button",
+ });
+ act = onAct;
+ const flag = sdk.useFeatureFlag("premium");
+ const title = sdk.useRemoteConfig("title");
+ const all = sdk.useFeatureFlags();
+ const configs = sdk.useRemoteConfigVariables();
+ return () =>
+ h(
+ "div",
+ `${variant.value}|${flag.value}|${title.value}|${all.value.length}|${configs.value.length}`
+ );
+ });
+ await flushPromises();
+ expect(wrapper.text()).toBe("Blue button|true|Hello Vue|2|3");
+ expect(send).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: AbbyEventType.PING,
+ data: {
+ projectId: "vue-test",
+ testName: "button",
+ selectedVariant: "blue",
+ },
+ })
+ );
+ act();
+ expect(send).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ type: AbbyEventType.ACT,
+ data: expect.objectContaining({ selectedVariant: "blue" }),
+ })
+ );
+ sdk.__abby__.updateFlag("premium", false);
+ sdk.__abby__.updateRemoteConfig("title", "Updated");
+ sdk.__abby__.updateLocalVariant("button", "control");
+ await nextTick();
+ expect(wrapper.text()).toBe("Original|false|Updated|2|3");
+ act();
+ expect(send).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ type: AbbyEventType.ACT,
+ data: expect.objectContaining({ selectedVariant: "control" }),
+ })
+ );
+ });
+
+ it("fetches centrally once and supports reactive names", async () => {
+ const fetcher = vi.fn(
+ async () => new Response(JSON.stringify(data), { status: 200 })
+ );
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ fetch: fetcher,
+ });
+ const name = ref<"premium" | "search">("premium");
+ const wrapper = render(
+ sdk,
+ () => {
+ const first = sdk.useFeatureFlag(name);
+ const second = sdk.useRemoteConfig("count");
+ return () => h("div", `${first.value}|${second.value}`);
+ },
+ null
+ );
+ await flushPromises();
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(fetcher.mock.calls[0][0]).toContain(
+ "api/v2/data/vue-test?environment=test"
+ );
+ expect(wrapper.text()).toBe("true|7");
+ name.value = "search";
+ await nextTick();
+ expect(wrapper.text()).toBe("false|7");
+ });
+
+ it("keeps configured defaults on a failed or partial API response", async () => {
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ settings: {
+ flags: { defaultValue: true },
+ remoteConfig: {
+ fallbackValues: { title: "Fallback" },
+ defaultValues: { Number: 42 },
+ },
+ },
+ fetch: async () => new Response("", { status: 503 }),
+ });
+ const wrapper = render(
+ sdk,
+ () => {
+ const flag = sdk.useFeatureFlag("search");
+ const title = sdk.useRemoteConfig("title");
+ const count = sdk.useRemoteConfig("count");
+ return () => h("div", `${flag.value}|${title.value}|${count.value}`);
+ },
+ null
+ );
+ await flushPromises();
+ expect(wrapper.text()).toBe("true|Fallback|42");
+ await wrapper.setProps({
+ initialData: { tests: [], flags: [], remoteConfig: [] },
+ });
+ expect(wrapper.text()).toBe("true|Fallback|42");
+ });
+
+ it("persists valid variants with configured expiry, ignores stale assignments and resets", async () => {
+ Cookie.set(getABStorageKey(config.projectId, "button"), "obsolete");
+ const set = vi.spyOn(Cookie, "set");
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ cookies: { expiresInDays: 3 },
+ });
+ render(sdk, () => {
+ const { variant } = sdk.useAbby("button");
+ return () => h("div", variant.value);
+ });
+ await flushPromises();
+ expect(sdk.getABTestValue("button")).toBe("blue");
+ expect(Cookie.get(getABStorageKey(config.projectId, "button"))).toBe(
+ "blue"
+ );
+ expect(set).toHaveBeenCalledWith(
+ getABStorageKey(config.projectId, "button"),
+ "blue",
+ { expires: 3 }
+ );
+ sdk.getABResetFunction("button")();
+ expect(
+ Cookie.get(getABStorageKey(config.projectId, "button"))
+ ).toBeUndefined();
+ });
+
+ it("does not write assignment cookies without consent and can enable/revoke consent", async () => {
+ const sdk = createAbby({
+ ...config,
+ environments: [...config.environments],
+ flags: [...config.flags],
+ cookies: { disableByDefault: true },
+ });
+ render(sdk, () => {
+ const { variant } = sdk.useAbby("button");
+ return () => h("div", variant.value);
+ });
+ await flushPromises();
+ expect(
+ Cookie.get(getABStorageKey(config.projectId, "button"))
+ ).toBeUndefined();
+ sdk.enableCookies();
+ expect(Cookie.get(getABStorageKey(config.projectId, "button"))).toBe(
+ "blue"
+ );
+ sdk.disableCookies();
+ expect(
+ Cookie.get(getABStorageKey(config.projectId, "button"))
+ ).toBeUndefined();
+ expect(Cookie.get(getABStorageKey(config.projectId, "$_abcc_$"))).toBe(
+ "false"
+ );
+ });
+
+ it("updates targeting rules and rejects invalid user properties", async () => {
+ const sdk = makeAbby();
+ const initialData: AbbyDataResponse = {
+ ...data,
+ flags: [
+ {
+ name: "premium",
+ value: false,
+ ruleSet: [
+ {
+ propertyName: "role",
+ propertyType: "string",
+ operator: "eq",
+ value: "admin",
+ thenValue: true,
+ },
+ ],
+ },
+ ],
+ };
+ const wrapper = render(
+ sdk,
+ () => {
+ const flag = sdk.useFeatureFlag("premium");
+ return () => h("div", String(flag.value));
+ },
+ initialData
+ );
+ sdk.updateUserProperties({ role: "admin" });
+ await nextTick();
+ expect(wrapper.text()).toBe("true");
+ expect(() => sdk.updateUserProperties({ role: 1 } as never)).toThrow();
+ expect(wrapper.text()).toBe("true");
+ });
+
+ it("requires the matching provider and rejects unknown runtime names", () => {
+ const sdk = makeAbby();
+ expect(() => sdk.useFeatureFlag("premium")).toThrow("AbbyProvider");
+ expect(() => sdk.getFeatureFlagValue("unknown" as never)).toThrow(
+ "Unknown Abby feature flag"
+ );
+ expect(() => sdk.getRemoteConfig("unknown" as never)).toThrow(
+ "Unknown Abby remote config"
+ );
+ expect(() => sdk.getABTestValue("unknown" as never)).toThrow(
+ "Unknown Abby test"
+ );
+ const other = makeAbby();
+ expect(() =>
+ render(sdk, () => {
+ other.useFeatureFlag("premium");
+ return () => h("div");
+ })
+ ).toThrow("AbbyProvider");
+ });
+
+ it("unsubscribes and stops events after unmount", async () => {
+ const sdk = makeAbby();
+ const unsubscribe = vi.fn();
+ const subscribe = sdk.__abby__.subscribe.bind(sdk.__abby__);
+ vi.spyOn(sdk.__abby__, "subscribe").mockImplementation((callback) => {
+ const stop = subscribe(callback);
+ return () => {
+ stop();
+ unsubscribe();
+ };
+ });
+ const send = vi
+ .spyOn(HttpService, "sendData")
+ .mockImplementation(() => undefined);
+ let act = () => {};
+ const wrapper = render(sdk, () => {
+ const result = sdk.useAbby("button");
+ act = result.onAct;
+ return () => h("div", result.variant.value);
+ });
+ await flushPromises();
+ wrapper.unmount();
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ send.mockClear();
+ sdk.__abby__.updateFlag("premium", false);
+ act();
+ await nextTick();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("mounts devtools only when requested and destroys them on unmount", () => {
+ const sdk = makeAbby();
+ const destroy = vi.fn();
+ const factory = {
+ create: vi.fn(
+ (_props: { abby: typeof sdk.__abby__; defaultShow?: boolean }) =>
+ destroy
+ ),
+ };
+ const Disabled = sdk.withDevtools(factory, {});
+ const disabled = mount(Disabled);
+ disabled.unmount();
+ expect(factory.create).not.toHaveBeenCalled();
+ const Enabled = sdk.withDevtools(factory, {
+ dangerouslyForceShow: true,
+ defaultShow: true,
+ });
+ const wrapper = mount(Enabled);
+ expect(factory.create).toHaveBeenCalledWith({
+ abby: sdk.__abby__,
+ defaultShow: true,
+ });
+ wrapper.unmount();
+ expect(destroy).toHaveBeenCalledTimes(1);
+ });
+
+ it("hydrates initial flags/config without mismatch and selects variants after mount", async () => {
+ function app() {
+ const sdk = makeAbby();
+ const Child = defineComponent({
+ setup() {
+ const flag = sdk.useFeatureFlag("premium");
+ const { variant } = sdk.useAbby("button");
+ return () => h("div", `${flag.value}|${variant.value ?? "pending"}`);
+ },
+ });
+ return createSSRApp({
+ render: () =>
+ h(
+ sdk.AbbyProvider,
+ { initialData: data },
+ { default: () => h(Child) }
+ ),
+ });
+ }
+ const html = await renderToString(app());
+ expect(html).toContain("true|pending");
+ const target = document.createElement("div");
+ target.innerHTML = html;
+ document.body.append(target);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const client = app();
+ client.mount(target);
+ await flushPromises();
+ expect(target.textContent).toBe("true|blue");
+ expect(warn).not.toHaveBeenCalled();
+ client.unmount();
+ target.remove();
+ });
+});
diff --git a/packages/vue/tests/ssr.test.ts b/packages/vue/tests/ssr.test.ts
new file mode 100644
index 00000000..df83e0eb
--- /dev/null
+++ b/packages/vue/tests/ssr.test.ts
@@ -0,0 +1,59 @@
+import { HttpService, getABStorageKey } from "@tryabby/core";
+// @vitest-environment node
+import { renderToString } from "@vue/server-renderer";
+import { expect, it, vi } from "vitest";
+import { createSSRApp, defineComponent, h } from "vue";
+import { data, makeAbby } from "./fixtures";
+
+it("isolates concurrent SSR requests, input config and request cookies", async () => {
+ const first = makeAbby();
+ const second = makeAbby();
+ const send = vi.spyOn(HttpService, "sendData");
+ function app(
+ sdk: ReturnType,
+ enabled: boolean,
+ variant: string
+ ) {
+ const Child = defineComponent({
+ setup() {
+ const flag = sdk.useFeatureFlag("premium");
+ const test = sdk.useAbby("button");
+ return () =>
+ h("div", `${flag.value}|${test.variant.value ?? "pending"}`);
+ },
+ });
+ return createSSRApp({
+ render: () =>
+ h(
+ sdk.AbbyProvider,
+ {
+ initialData: {
+ ...data,
+ flags: [{ name: "premium", value: enabled }],
+ },
+ cookies: `${getABStorageKey("vue-test", "button")}=${variant}`,
+ },
+ { default: () => h(Child) }
+ ),
+ });
+ }
+ const result = await Promise.all([
+ renderToString(app(first, true, "control")),
+ renderToString(app(second, false, "blue")),
+ ]);
+ expect(result[0]).toContain("true|pending");
+ expect(result[1]).toContain("false|pending");
+ expect(first.getABTestValue("button")).toBe("control");
+ expect(second.getABTestValue("button")).toBe("blue");
+ first.disableCookies();
+ expect(second.__abby__.getConfig().cookies?.disableByDefault).not.toBe(true);
+ expect(send).not.toHaveBeenCalled();
+});
+
+it("rejects accidental reuse across SSR applications instead of leaking user state", async () => {
+ const sdk = makeAbby();
+ const app = () =>
+ createSSRApp({ render: () => h(sdk.AbbyProvider, { initialData: data }) });
+ await renderToString(app());
+ await expect(renderToString(app())).rejects.toThrow("fresh Abby instance");
+});
diff --git a/packages/vue/tests/types.ts b/packages/vue/tests/types.ts
new file mode 100644
index 00000000..ceceeec7
--- /dev/null
+++ b/packages/vue/tests/types.ts
@@ -0,0 +1,46 @@
+import { expectTypeOf } from "vitest";
+import type { ComputedRef } from "vue";
+import { makeAbby } from "./fixtures";
+
+// Compiled by tsc; never executed outside a component's setup().
+export function checkTypes() {
+ const sdk = makeAbby();
+ expectTypeOf(sdk.useFeatureFlag("premium")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(sdk.useRemoteConfig("title")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(sdk.useRemoteConfig("count")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(sdk.useRemoteConfig("theme")).toEqualTypeOf<
+ ComputedRef>
+ >();
+ expectTypeOf(sdk.useAbby("button").variant).toEqualTypeOf<
+ ComputedRef<"control" | "blue" | undefined>
+ >();
+ expectTypeOf(
+ sdk.useAbby("button", { control: 1, blue: "two" }).variant
+ ).toEqualTypeOf>();
+ expectTypeOf(sdk.getABTestValue("button")).toEqualTypeOf<
+ "control" | "blue"
+ >();
+ expectTypeOf(sdk.getRemoteConfig("count")).toEqualTypeOf();
+ expectTypeOf(sdk.getVariants("button")).toEqualTypeOf<
+ readonly ["control", "blue"]
+ >();
+ sdk.updateUserProperties({ role: "admin", age: 18, active: undefined });
+ // @ts-expect-error Unknown feature flag
+ sdk.useFeatureFlag("missing");
+ // @ts-expect-error Unknown remote config
+ sdk.useRemoteConfig("missing");
+ // @ts-expect-error Unknown A/B test
+ sdk.useAbby("missing");
+ // @ts-expect-error Missing a variant in the lookup
+ sdk.useAbby("button", { control: 1 });
+ // @ts-expect-error Invalid user property type
+ sdk.updateUserProperties({ age: "18" });
+ // @ts-expect-error Unknown user property
+ sdk.updateUserProperties({ missing: true });
+}
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
new file mode 100644
index 00000000..b79120ba
--- /dev/null
+++ b/packages/vue/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "tsconfig/base.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ESNext", "DOM"],
+ "types": ["node"],
+ "noEmit": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/vue/tsconfig.test.json b/packages/vue/tsconfig.test.json
new file mode 100644
index 00000000..c503db22
--- /dev/null
+++ b/packages/vue/tsconfig.test.json
@@ -0,0 +1,5 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": { "types": ["node", "vitest/globals"] },
+ "include": ["src", "tests"]
+}
diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts
new file mode 100644
index 00000000..bf89044f
--- /dev/null
+++ b/packages/vue/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ dts: true,
+ clean: true,
+ format: ["cjs", "esm"],
+ sourcemap: true,
+ treeshake: true,
+});
diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts
new file mode 100644
index 00000000..f8af668d
--- /dev/null
+++ b/packages/vue/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ environmentOptions: { jsdom: { url: "https://vue.example.test" } },
+ include: ["tests/**/*.test.ts"],
+ restoreMocks: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69fa896e..efa72226 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1108,6 +1108,49 @@ importers:
packages/tsconfig: {}
+ packages/vue:
+ dependencies:
+ "@tryabby/core":
+ specifier: workspace:*
+ version: link:../core
+ js-cookie:
+ specifier: ^3.0.1
+ version: 3.0.5
+ devDependencies:
+ "@types/js-cookie":
+ specifier: ^3.0.3
+ version: 3.0.3
+ "@types/node":
+ specifier: ^22.5.1
+ version: 22.20.5
+ "@vue/server-renderer":
+ specifier: 3.5.13
+ version: 3.5.13(vue@3.5.13(typescript@5.5.4))
+ "@vue/test-utils":
+ specifier: 2.4.6
+ version: 2.4.6
+ jsdom:
+ specifier: ^20.0.3
+ version: 20.0.3
+ tsconfig:
+ specifier: workspace:*
+ version: link:../tsconfig
+ tsup:
+ specifier: ^6.5.0
+ version: 6.7.0(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4))(typescript@5.5.4)
+ typescript:
+ specifier: 5.5.4
+ version: 5.5.4
+ vite:
+ specifier: 5.4.0
+ version: 5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
+ vitest:
+ specifier: 2.0.5
+ version: 2.0.5(@types/node@22.20.5)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
+ vue:
+ specifier: 3.5.13
+ version: 3.5.13(typescript@5.5.4)
+
packages:
'@adobe/css-tools@4.2.0':
@@ -14389,6 +14432,99 @@ packages:
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+ "@types/node@22.20.5":
+ resolution:
+ integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==
+ undici-types@6.21.0:
+ resolution:
+ integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
+ "@vue/server-renderer@3.5.13":
+ resolution:
+ integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==
+ peerDependencies:
+ vue: 3.5.13
+ "@vue/compiler-ssr@3.5.13":
+ resolution:
+ integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==
+ "@vue/compiler-dom@3.5.13":
+ resolution:
+ integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==
+ "@vue/compiler-core@3.5.13":
+ resolution:
+ integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==
+ "@vue/shared@3.5.13":
+ resolution:
+ integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
+ vue@3.5.13:
+ resolution:
+ integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==
+ peerDependencies:
+ typescript: "*"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ "@vue/compiler-sfc@3.5.13":
+ resolution:
+ integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==
+ magic-string@0.30.21:
+ resolution:
+ integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+ "@jridgewell/sourcemap-codec@1.6.0":
+ resolution:
+ integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==
+ postcss@8.5.31:
+ resolution:
+ integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==
+ engines:
+ node: ^10 || ^12 || >=14
+ nanoid@3.3.20:
+ resolution:
+ integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==
+ engines:
+ node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1
+ hasBin: true
+ picocolors@1.1.1:
+ resolution:
+ integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+ source-map-js@1.2.2:
+ resolution:
+ integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==
+ engines:
+ node: ">=0.10.0"
+ "@vue/runtime-dom@3.5.13":
+ resolution:
+ integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==
+ "@vue/reactivity@3.5.13":
+ resolution:
+ integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==
+ "@vue/runtime-core@3.5.13":
+ resolution:
+ integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==
+ csstype@3.2.3:
+ resolution:
+ integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+ "@vue/test-utils@2.4.6":
+ resolution:
+ integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==
+ js-beautify@1.15.4:
+ resolution:
+ integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==
+ engines:
+ node: ">=14"
+ hasBin: true
+ editorconfig@1.0.7:
+ resolution:
+ integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==
+ engines:
+ node: ">=14"
+ hasBin: true
+ "@one-ini/wasm@0.1.1":
+ resolution:
+ integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==
+ vue-component-type-helpers@2.2.12:
+ resolution:
+ integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==
+
snapshots:
'@adobe/css-tools@4.2.0': {}
@@ -31399,3 +31535,202 @@ snapshots:
react: 18.2.0
zwitch@2.0.4: {}
+
+ "@types/node@22.20.5":
+ dependencies:
+ undici-types: 6.21.0
+ undici-types@6.21.0: {}
+ "@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.5.4))":
+ dependencies:
+ "@vue/compiler-ssr": 3.5.13
+ "@vue/shared": 3.5.13
+ vue: 3.5.13(typescript@5.5.4)
+ "@vue/compiler-ssr@3.5.13":
+ dependencies:
+ "@vue/compiler-dom": 3.5.13
+ "@vue/shared": 3.5.13
+ "@vue/compiler-dom@3.5.13":
+ dependencies:
+ "@vue/compiler-core": 3.5.13
+ "@vue/shared": 3.5.13
+ "@vue/compiler-core@3.5.13":
+ dependencies:
+ "@babel/parser": 7.25.3
+ "@vue/shared": 3.5.13
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.0
+ "@vue/shared@3.5.13": {}
+ vue@3.5.13(typescript@5.5.4):
+ dependencies:
+ "@vue/compiler-dom": 3.5.13
+ "@vue/compiler-sfc": 3.5.13
+ "@vue/runtime-dom": 3.5.13
+ "@vue/server-renderer": 3.5.13(vue@3.5.13(typescript@5.5.4))
+ "@vue/shared": 3.5.13
+ optionalDependencies:
+ typescript: 5.5.4
+ "@vue/compiler-sfc@3.5.13":
+ dependencies:
+ "@babel/parser": 7.25.3
+ "@vue/compiler-core": 3.5.13
+ "@vue/compiler-dom": 3.5.13
+ "@vue/compiler-ssr": 3.5.13
+ "@vue/shared": 3.5.13
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.31
+ source-map-js: 1.2.0
+ magic-string@0.30.21:
+ dependencies:
+ "@jridgewell/sourcemap-codec": 1.6.0
+ "@jridgewell/sourcemap-codec@1.6.0": {}
+ postcss@8.5.31:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+ nanoid@3.3.20: {}
+ picocolors@1.1.1: {}
+ source-map-js@1.2.2: {}
+ "@vue/runtime-dom@3.5.13":
+ dependencies:
+ "@vue/reactivity": 3.5.13
+ "@vue/runtime-core": 3.5.13
+ "@vue/shared": 3.5.13
+ csstype: 3.2.3
+ "@vue/reactivity@3.5.13":
+ dependencies:
+ "@vue/shared": 3.5.13
+ "@vue/runtime-core@3.5.13":
+ dependencies:
+ "@vue/reactivity": 3.5.13
+ "@vue/shared": 3.5.13
+ csstype@3.2.3: {}
+ "@vue/test-utils@2.4.6":
+ dependencies:
+ js-beautify: 1.15.4
+ vue-component-type-helpers: 2.2.12
+ js-beautify@1.15.4:
+ dependencies:
+ config-chain: 1.1.13
+ editorconfig: 1.0.7
+ glob: 10.4.5
+ js-cookie: 3.0.5
+ nopt: 7.2.1
+ editorconfig@1.0.7:
+ dependencies:
+ "@one-ini/wasm": 0.1.1
+ commander: 10.0.1
+ minimatch: 9.0.4
+ semver: 7.6.2
+ "@one-ini/wasm@0.1.1": {}
+ vue-component-type-helpers@2.2.12: {}
+ tsup@6.7.0(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4))(typescript@5.5.4):
+ dependencies:
+ bundle-require: 4.0.1(esbuild@0.17.19)
+ cac: 6.7.14
+ chokidar: 3.6.0
+ debug: 4.3.5
+ esbuild: 0.17.19
+ execa: 5.1.1
+ globby: 11.1.0
+ joycon: 3.1.1
+ postcss-load-config: 3.1.4(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4))
+ resolve-from: 5.0.0
+ rollup: 3.29.4
+ source-map: 0.8.0-beta.0
+ sucrase: 3.32.0
+ tree-kill: 1.2.2
+ optionalDependencies:
+ postcss: 8.5.31
+ typescript: 5.5.4
+ transitivePeerDependencies:
+ - supports-color
+ - ts-node
+ postcss-load-config@3.1.4(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4)):
+ dependencies:
+ lilconfig: 2.1.0
+ yaml: 1.10.2
+ optionalDependencies:
+ postcss: 8.5.31
+ ts-node: 10.9.1(@types/node@22.20.5)(typescript@5.5.4)
+ ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4):
+ dependencies:
+ "@cspotcode/source-map-support": 0.8.1
+ "@tsconfig/node10": 1.0.9
+ "@tsconfig/node12": 1.0.11
+ "@tsconfig/node14": 1.0.3
+ "@tsconfig/node16": 1.0.4
+ "@types/node": 22.20.5
+ acorn: 8.8.2
+ acorn-walk: 8.2.0
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.5.4
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+ optional: true
+ vite@5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.4.41
+ rollup: 4.18.0
+ optionalDependencies:
+ "@types/node": 22.20.5
+ fsevents: 2.3.3
+ less: 4.2.0
+ sass: 1.77.8
+ terser: 5.31.1
+ vitest@2.0.5(@types/node@22.20.5)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1):
+ dependencies:
+ "@ampproject/remapping": 2.3.0
+ "@vitest/expect": 2.0.5
+ "@vitest/pretty-format": 2.0.5
+ "@vitest/runner": 2.0.5
+ "@vitest/snapshot": 2.0.5
+ "@vitest/spy": 2.0.5
+ "@vitest/utils": 2.0.5
+ chai: 5.1.1
+ debug: 4.3.5
+ execa: 8.0.1
+ magic-string: 0.30.10
+ pathe: 1.1.2
+ std-env: 3.7.0
+ tinybench: 2.8.0
+ tinypool: 1.0.0
+ tinyrainbow: 1.2.0
+ vite: 5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
+ vite-node: 2.0.5(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ "@types/node": 22.20.5
+ jsdom: 20.0.3
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ vite-node@2.0.5(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.3.5
+ pathe: 1.1.2
+ tinyrainbow: 1.2.0
+ vite: 5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
+ transitivePeerDependencies:
+ - "@types/node"
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser