@approck/nuxt-unleash 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Bluecoaster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,144 @@
1
- # Temporary Holding Version
1
+ <!--
2
+ Get your module up and running quickly.
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ Find and replace all on all files (CMD+SHIFT+F):
5
+ - Name: My Module
6
+ - Package name: my-module
7
+ - Description: My new Nuxt module
8
+ -->
9
+
10
+ # Nuxt Unleash Module
11
+
12
+ Feature flags from [Unleash](https://www.getunleash.io) — including [GitLab feature flags](https://docs.gitlab.com/operations/feature_flags/), which serve an Unleash-compatible API — for Nuxt 3 and 4, evaluated on the server and delivered with the page.
13
+
14
+ ## Features
15
+
16
+ - Flags are evaluated on the server with the Node SDK ([unleash-client](https://www.npmjs.com/package/unleash-client)) for the context of each request, and handed to the browser in the payload: the first HTML already has them, nothing appears after hydration.
17
+ - One client per server process keeps the flag definitions in memory; every evaluation gets its own context, so concurrent requests of different people never share state.
18
+ - The browser reads the flags again, in one request, only when the context changes (login, logout, another account).
19
+ - With [@sidebase/nuxt-auth](https://auth.sidebase.io) the flags are evaluated for the signed-in person, and with [@sentry/nuxt](https://docs.sentry.io/platforms/javascript/guides/nuxt/) failures are reported — nothing to write in the app.
20
+ - `useFeatureFlag(name)` is synchronous.
21
+ - Server routes can evaluate flags themselves through `#unleash`.
22
+
23
+ ## Quick Setup
24
+
25
+ ```bash
26
+ npm install @approck/nuxt-unleash
27
+ ```
28
+
29
+ ```ts
30
+ export default defineNuxtConfig({
31
+ modules: ['@approck/nuxt-unleash'],
32
+ unleash: {
33
+ // GitLab: Deploy > Feature flags > Configure
34
+ url: 'https://gitlab.example.com/api/v4/feature_flags/unleash/42',
35
+ instanceId: 'glffct-…',
36
+ // The environment the app runs in: GitLab scopes flag strategies to environments by this name.
37
+ appName: 'production',
38
+ },
39
+ })
40
+ ```
41
+
42
+ The settings are server-only runtime config, so they can be set at deploy time instead:
43
+ `NUXT_UNLEASH_URL`, `NUXT_UNLEASH_INSTANCE_ID`, `NUXT_UNLEASH_APP_NAME`.
44
+
45
+ | Option | Default | |
46
+ | --- | --- | --- |
47
+ | `url` | — | Unleash client API URL (GitLab: API URL). |
48
+ | `instanceId` | — | GitLab: Instance ID. Never sent to the browser. |
49
+ | `appName` | — | GitLab: environment name. |
50
+ | `refreshInterval` | `15` | Seconds between refreshes of the flag definitions. |
51
+ | `readyTimeout` | `5` | Seconds after the server starts in which renders wait, together, for the first definitions. No render waits after that: without definitions the flags are off until they arrive. |
52
+ | `customApiPath` | `'unleash'` | The browser reads flags from `POST /api/<customApiPath>/features`. |
53
+
54
+ Without `url`, `instanceId` or `appName` the app still starts; every flag is off and `unleash:error` is called.
55
+
56
+ ## Usage
57
+
58
+ With @sidebase/nuxt-auth installed the module evaluates flags for the session's `id` as `userId`. nuxt-auth reads the session on the server, so the first HTML has that person's flags; login, logout and another account read them again.
59
+
60
+ Without nuxt-auth the app tells the module whom to evaluate for. Write the context in a plugin — the module's own plugin runs after the app's plugins — and again whenever the person changes:
61
+
62
+ ```ts
63
+ // plugins/unleash-context.ts
64
+ export default defineNuxtPlugin(() => {
65
+ const userId = useCookie<string | null>('user-id')
66
+ useUnleashContext().value = userId.value ? { userId: userId.value } : {}
67
+ })
68
+ ```
69
+
70
+ Read flags anywhere:
71
+
72
+ ```ts
73
+ const canvasEnabled = useFeatureFlag('canvas') // ComputedRef<boolean>
74
+ const flags = useFeatureFlags() // Ref<string[] | null>: names of the flags that are on
75
+ ```
76
+
77
+ A read that failed leaves every flag off and calls `unleash:error`. With @sentry/nuxt installed the module sends it to Sentry itself; otherwise subscribe to the hook:
78
+
79
+ ```ts
80
+ export default defineNuxtPlugin((nuxtApp) => {
81
+ nuxtApp.hook('unleash:error', error => console.error(error))
82
+ })
83
+ ```
84
+
85
+ In a server route:
86
+
87
+ ```ts
88
+ import { useUnleash } from '#unleash'
89
+
90
+ export default defineEventHandler(async () => {
91
+ const client = await useUnleash()
92
+ return client.isEnabled('my-flag', { userId: '42' })
93
+ })
94
+ ```
95
+
96
+ The context the browser posts is not verified: flags gate the interface, not access — whatever a flag hides must still be checked by the API.
97
+
98
+ ## Migrating from 0.1
99
+
100
+ - Options: `clientKey` and `environment` are gone; set `instanceId` instead. `url` points at the Unleash client API (GitLab's API URL), not at an Unleash Proxy.
101
+ - `useUnleash().isFeatureEnabled(flag, ctx)` is replaced by `useFeatureFlag(flag)`; the context is set once with `useUnleashContext()`.
102
+ - On the server, `#unleash` exports `useUnleash()`, which resolves to the `unleash-client` instance: `isEnabled(name, context)`.
103
+
104
+ ## Contribution
105
+
106
+ <details>
107
+ <summary>Local development</summary>
108
+
109
+ ```bash
110
+ # Install dependencies
111
+ npm install
112
+
113
+ # Generate type stubs
114
+ npm run dev:prepare
115
+
116
+ # Develop with the playground
117
+ npm run dev
118
+
119
+ # Build the playground
120
+ npm run dev:build
121
+
122
+ # Run ESLint
123
+ npm run lint
124
+
125
+ # Run Vitest
126
+ npm run test
127
+ npm run test:watch
128
+
129
+ # Release new version
130
+ npm run release
131
+ ```
132
+
133
+ </details>
134
+
135
+ <!-- Badges -->
136
+
137
+ [npm-version-src]: https://img.shields.io/npm/v/@approck/nuxt-unleash/latest.svg?style=flat&colorA=020420&colorB=00DC82
138
+ [npm-version-href]: https://npmjs.com/package/@approck/nuxt-unleash
139
+ [npm-downloads-src]: https://img.shields.io/npm/dm/@approck/nuxt-unleash.svg?style=flat&colorA=020420&colorB=00DC82
140
+ [npm-downloads-href]: https://npm.chart.dev/@approck/nuxt-unleash
141
+ [license-src]: https://img.shields.io/npm/l/@approck/nuxt-unleash.svg?style=flat&colorA=020420&colorB=00DC82
142
+ [license-href]: https://npmjs.com/package/@approck/nuxt-unleash
143
+ [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js
144
+ [nuxt-href]: https://nuxt.com
@@ -0,0 +1,5 @@
1
+ module.exports = function(...args) {
2
+ return import('./module.mjs').then(m => m.default.call(this, ...args))
3
+ }
4
+ const _meta = module.exports.meta = require('./module.json')
5
+ module.exports.getMeta = () => Promise.resolve(_meta)
@@ -0,0 +1,24 @@
1
+ import { defineNuxtModule } from '@nuxt/kit';
2
+ export { FeatureFlagsEvaluation, FeatureFlagsUnavailable, UnleashContext } from '../dist/runtime/types.js';
3
+
4
+ interface ModuleOptions {
5
+ /** Unleash client API URL. GitLab: Deploy > Feature flags > Configure > API URL. */
6
+ url: string;
7
+ /** GitLab: Instance ID. Server-only: it authorizes reading the flags. */
8
+ instanceId: string;
9
+ /** GitLab: the environment the app runs in — flag strategies are scoped to environments by this name. */
10
+ appName: string;
11
+ /** Seconds between refreshes of the flag definitions on the server. */
12
+ refreshInterval: number;
13
+ /**
14
+ * Seconds after the server starts in which renders wait, together, for the first definitions. No render waits
15
+ * after that: without definitions the flags are off until they arrive.
16
+ */
17
+ readyTimeout: number;
18
+ /** Path segment of the endpoint the browser reads flags from: `/api/<customApiPath>/features`. */
19
+ customApiPath: string;
20
+ }
21
+
22
+ declare const _default: ReturnType<typeof defineNuxtModule<ModuleOptions>>;
23
+
24
+ export { type ModuleOptions, _default as default };
@@ -0,0 +1,24 @@
1
+ import { defineNuxtModule } from '@nuxt/kit';
2
+ export { FeatureFlagsEvaluation, FeatureFlagsUnavailable, UnleashContext } from '../dist/runtime/types.js';
3
+
4
+ interface ModuleOptions {
5
+ /** Unleash client API URL. GitLab: Deploy > Feature flags > Configure > API URL. */
6
+ url: string;
7
+ /** GitLab: Instance ID. Server-only: it authorizes reading the flags. */
8
+ instanceId: string;
9
+ /** GitLab: the environment the app runs in — flag strategies are scoped to environments by this name. */
10
+ appName: string;
11
+ /** Seconds between refreshes of the flag definitions on the server. */
12
+ refreshInterval: number;
13
+ /**
14
+ * Seconds after the server starts in which renders wait, together, for the first definitions. No render waits
15
+ * after that: without definitions the flags are off until they arrive.
16
+ */
17
+ readyTimeout: number;
18
+ /** Path segment of the endpoint the browser reads flags from: `/api/<customApiPath>/features`. */
19
+ customApiPath: string;
20
+ }
21
+
22
+ declare const _default: ReturnType<typeof defineNuxtModule<ModuleOptions>>;
23
+
24
+ export { type ModuleOptions, _default as default };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@approck/nuxt-unleash",
3
+ "configKey": "unleash",
4
+ "version": "0.2.0",
5
+ "builder": {
6
+ "@nuxt/module-builder": "0.8.4",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -0,0 +1,71 @@
1
+ import { defineNuxtModule, createResolver, addServerPlugin, addServerHandler, hasNuxtModule, addPlugin, addImports, addTypeTemplate } from '@nuxt/kit';
2
+ import { defu } from 'defu';
3
+
4
+ const moduleDefinition = defineNuxtModule({
5
+ meta: {
6
+ name: "@approck/nuxt-unleash",
7
+ configKey: "unleash"
8
+ },
9
+ defaults: {
10
+ url: "",
11
+ instanceId: "",
12
+ appName: "",
13
+ refreshInterval: 15,
14
+ readyTimeout: 5,
15
+ customApiPath: "unleash"
16
+ },
17
+ setup(options, nuxt) {
18
+ const { resolve } = createResolver(import.meta.url);
19
+ const { customApiPath, ...serverOptions } = options;
20
+ nuxt.options.runtimeConfig.unleash = defu(
21
+ nuxt.options.runtimeConfig.unleash,
22
+ serverOptions
23
+ );
24
+ const apiPath = `/api/${customApiPath}/features`;
25
+ nuxt.options.runtimeConfig.public.unleash = defu(
26
+ nuxt.options.runtimeConfig.public.unleash,
27
+ { apiPath }
28
+ );
29
+ addServerPlugin(resolve("./runtime/server/plugin"));
30
+ addServerHandler({
31
+ route: apiPath,
32
+ method: "post",
33
+ handler: resolve("./runtime/server/api/features.post")
34
+ });
35
+ nuxt.hook("modules:done", () => {
36
+ if (hasNuxtModule("@sidebase/nuxt-auth", nuxt)) {
37
+ addPlugin({ src: resolve("./runtime/plugins/auth-context"), mode: "all" }, { append: true });
38
+ }
39
+ if (hasNuxtModule("@sentry/nuxt/module", nuxt)) {
40
+ addPlugin({ src: resolve("./runtime/plugins/sentry"), mode: "all" }, { append: true });
41
+ }
42
+ addPlugin({ src: resolve("./runtime/plugin"), mode: "all" }, { append: true });
43
+ });
44
+ addImports(["useFeatureFlag", "useFeatureFlags", "useUnleashContext"].map((name) => ({
45
+ name,
46
+ from: resolve("./runtime/composables")
47
+ })));
48
+ nuxt.hook("nitro:config", (nitroConfig) => {
49
+ nitroConfig.alias = nitroConfig.alias || {};
50
+ nitroConfig.externals = defu(
51
+ typeof nitroConfig.externals === "object" ? nitroConfig.externals : {},
52
+ {
53
+ inline: [resolve("./runtime")]
54
+ }
55
+ );
56
+ nitroConfig.alias["#unleash"] = resolve("./runtime/server/services");
57
+ });
58
+ addTypeTemplate({
59
+ filename: "types/nuxt-unleash.d.ts",
60
+ getContents: () => [
61
+ "// AUTO-GENERATED BY @approck/nuxt-unleash",
62
+ "declare module '#unleash' {",
63
+ ` const { useUnleash }: typeof import('${resolve("./runtime/server/services")}')`,
64
+ "}",
65
+ ""
66
+ ].join("\n")
67
+ });
68
+ }
69
+ });
70
+
71
+ export { moduleDefinition as default };
@@ -0,0 +1,14 @@
1
+ import { type ComputedRef, type Ref } from 'vue';
2
+ import type { UnleashContext } from '../types.js';
3
+ /**
4
+ * The context flags are evaluated for. The application writes it — typically in a plugin, from its session — before
5
+ * the module's plugin runs (that one is enforced after the app's plugins), and again whenever the person changes.
6
+ */
7
+ export declare function useUnleashContext(): Ref<UnleashContext>;
8
+ /**
9
+ * Names of the flags that are on for the current context. Evaluated on the server and delivered with the page, so
10
+ * they are known from the first render; `null` only on a page rendered without SSR, until the browser reads them.
11
+ */
12
+ export declare function useFeatureFlags(): Ref<string[] | null>;
13
+ /** Whether a flag is on for the current context; off while the flags are not known. */
14
+ export declare function useFeatureFlag(name: string): ComputedRef<boolean>;
@@ -0,0 +1,12 @@
1
+ import { computed } from "vue";
2
+ import { useState } from "#app";
3
+ export function useUnleashContext() {
4
+ return useState("unleash-context", () => ({}));
5
+ }
6
+ export function useFeatureFlags() {
7
+ return useState("unleash-flags", () => null);
8
+ }
9
+ export function useFeatureFlag(name) {
10
+ const flags = useFeatureFlags();
11
+ return computed(() => flags.value?.includes(name) === true);
12
+ }
@@ -0,0 +1,15 @@
1
+ import { FeatureFlagsUnavailable } from './types.js';
2
+ declare module '#app' {
3
+ interface RuntimeNuxtHooks {
4
+ /** Flags could not be read and are all off for now — report it (e.g. to Sentry). */
5
+ 'unleash:error': (error: FeatureFlagsUnavailable) => void | Promise<void>;
6
+ }
7
+ }
8
+ /**
9
+ * Server: evaluates every flag for the context the app wrote and hands the result to the browser in the payload.
10
+ * Browser: reads the flags again only when that context changes; a page rendered without SSR reads them once first.
11
+ * Flags of the previous context stay until the new ones arrive, so the interface does not blink on login or logout.
12
+ * A failed read turns every flag off and calls the `unleash:error` hook.
13
+ */
14
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
15
+ export default _default;
@@ -0,0 +1,51 @@
1
+ import { watch } from "vue";
2
+ import { FetchError } from "ofetch";
3
+ import { FeatureFlagsUnavailable } from "./types.js";
4
+ import { useFeatureFlags, useUnleashContext } from "./composables/index.js";
5
+ import { defineNuxtPlugin, useRequestEvent, useRuntimeConfig } from "#app";
6
+ export default defineNuxtPlugin({
7
+ name: "@approck/nuxt-unleash",
8
+ // After the app's own plugins: they write the context and subscribe to `unleash:error` first.
9
+ enforce: "post",
10
+ async setup(nuxtApp) {
11
+ const flags = useFeatureFlags();
12
+ const context = useUnleashContext();
13
+ const fail = async (error) => {
14
+ flags.value = [];
15
+ await nuxtApp.callHook("unleash:error", error);
16
+ };
17
+ if (import.meta.server) {
18
+ const evaluate = useRequestEvent()?.context.unleashEvaluate;
19
+ if (!evaluate) throw new Error("@approck/nuxt-unleash: the server plugin did not attach unleashEvaluate");
20
+ const outcome = await evaluate({ ...context.value });
21
+ if ("unavailable" in outcome) {
22
+ await fail(new FeatureFlagsUnavailable(outcome.unavailable));
23
+ return;
24
+ }
25
+ flags.value = outcome.enabled;
26
+ return;
27
+ }
28
+ const { apiPath } = useRuntimeConfig().public.unleash;
29
+ let latestRead = 0;
30
+ const read = async () => {
31
+ const run = ++latestRead;
32
+ try {
33
+ const result = await $fetch(apiPath, {
34
+ method: "POST",
35
+ body: { context: context.value }
36
+ });
37
+ if (run === latestRead) flags.value = result.enabled;
38
+ } catch (error) {
39
+ if (!(error instanceof FetchError)) throw error;
40
+ if (run !== latestRead) return;
41
+ await fail(new FeatureFlagsUnavailable("the flags endpoint did not answer", { cause: error }));
42
+ }
43
+ };
44
+ if (!nuxtApp.payload.serverRendered) {
45
+ await read();
46
+ }
47
+ watch(() => JSON.stringify(context.value), () => {
48
+ void read();
49
+ });
50
+ }
51
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Evaluates flags for the signed-in person of @sidebase/nuxt-auth: `userId` is the session's `id`. nuxt-auth reads
3
+ * the session on the server before the page renders, so the first HTML already has that person's flags; login,
4
+ * logout and another account change the context, and the module's plugin reads the flags again.
5
+ */
6
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
7
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { watch } from "vue";
2
+ import { useUnleashContext } from "../composables/index.js";
3
+ import { defineNuxtPlugin } from "#app";
4
+ import { useAuth } from "#imports";
5
+ export default defineNuxtPlugin({
6
+ name: "@approck/nuxt-unleash:auth-context",
7
+ // After nuxt-auth's own plugin has read the session; registered before the module's plugin, which reads the context.
8
+ enforce: "post",
9
+ setup() {
10
+ const { data } = useAuth();
11
+ const context = useUnleashContext();
12
+ watch(() => data.value?.id, (id) => {
13
+ context.value = id == null ? {} : { userId: String(id) };
14
+ }, { immediate: true });
15
+ }
16
+ });
@@ -0,0 +1,3 @@
1
+ /** Sends every `unleash:error` to the app's Sentry, on the server and in the browser. */
2
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
3
+ export default _default;
@@ -0,0 +1,10 @@
1
+ import * as Sentry from "@sentry/nuxt";
2
+ import { defineNuxtPlugin } from "#app";
3
+ export default defineNuxtPlugin({
4
+ name: "@approck/nuxt-unleash:sentry",
5
+ setup(nuxtApp) {
6
+ nuxtApp.hook("unleash:error", (error) => {
7
+ Sentry.captureException(error);
8
+ });
9
+ }
10
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,35 @@
1
+ import { createError, defineEventHandler, readBody } from "#imports";
2
+ const CONTEXT_FIELDS = ["userId", "sessionId", "remoteAddress"];
3
+ function readContext(body) {
4
+ const source = body?.context;
5
+ if (typeof source !== "object" || source === null) {
6
+ throw createError({ statusCode: 400, statusMessage: "body.context must be an object" });
7
+ }
8
+ const context = {};
9
+ for (const field of CONTEXT_FIELDS) {
10
+ const value = source[field];
11
+ if (value === void 0) continue;
12
+ if (typeof value !== "string") {
13
+ throw createError({ statusCode: 400, statusMessage: `body.context.${field} must be a string` });
14
+ }
15
+ context[field] = value;
16
+ }
17
+ const properties = source.properties;
18
+ if (properties !== void 0) {
19
+ if (typeof properties !== "object" || properties === null || Object.values(properties).some((value) => typeof value !== "string")) {
20
+ throw createError({ statusCode: 400, statusMessage: "body.context.properties must map strings to strings" });
21
+ }
22
+ context.properties = properties;
23
+ }
24
+ return context;
25
+ }
26
+ export default defineEventHandler(async (event) => {
27
+ const context = readContext(await readBody(event));
28
+ const evaluate = event.context.unleashEvaluate;
29
+ if (!evaluate) throw new Error("@approck/nuxt-unleash: the server plugin did not attach unleashEvaluate");
30
+ const outcome = await evaluate(context);
31
+ if ("unavailable" in outcome) {
32
+ throw createError({ statusCode: 503, statusMessage: `Feature flags are unavailable: ${outcome.unavailable}` });
33
+ }
34
+ return outcome;
35
+ });
@@ -0,0 +1,12 @@
1
+ import { type FeatureFlagsOutcome, type UnleashContext } from '../types.js';
2
+ declare module 'h3' {
3
+ interface H3EventContext {
4
+ /**
5
+ * Evaluates every flag for a context with this server's Unleash client. The Nuxt app is bundled apart from
6
+ * Nitro, so it reaches the one client through the request instead of importing its own copy.
7
+ */
8
+ unleashEvaluate?: (context: UnleashContext) => Promise<FeatureFlagsOutcome>;
9
+ }
10
+ }
11
+ declare const _default: any;
12
+ export default _default;
@@ -0,0 +1,22 @@
1
+ import { FeatureFlagsUnavailable } from "../types.js";
2
+ import { startUnleash, evaluateFlags } from "./unleash.js";
3
+ import { defineNitroPlugin, useRuntimeConfig } from "#imports";
4
+ export default defineNitroPlugin((nitroApp) => {
5
+ const config = useRuntimeConfig().unleash;
6
+ try {
7
+ startUnleash(config);
8
+ } catch (error) {
9
+ if (!(error instanceof FeatureFlagsUnavailable)) throw error;
10
+ console.error("[unleash]", error.message);
11
+ }
12
+ nitroApp.hooks.hook("request", (event) => {
13
+ event.context.unleashEvaluate = async (context) => {
14
+ try {
15
+ return await evaluateFlags(config, context);
16
+ } catch (error) {
17
+ if (!(error instanceof FeatureFlagsUnavailable)) throw error;
18
+ return { unavailable: error.reason };
19
+ }
20
+ };
21
+ });
22
+ });
@@ -0,0 +1,2 @@
1
+ /** The server's Unleash client once its definitions are loaded: `isEnabled(name, context)` for server routes. */
2
+ export declare function useUnleash(): Promise<import("unleash-client").Unleash>;
@@ -0,0 +1,5 @@
1
+ import { useUnleashClient } from "../unleash.js";
2
+ import { useRuntimeConfig } from "#imports";
3
+ export function useUnleash() {
4
+ return useUnleashClient(useRuntimeConfig().unleash);
5
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json",
3
+ }
@@ -0,0 +1,23 @@
1
+ import { Unleash } from 'unleash-client';
2
+ import { type FeatureFlagsEvaluation, type UnleashContext } from '../types.js';
3
+ export interface UnleashServerConfig {
4
+ url: string;
5
+ instanceId: string;
6
+ appName: string;
7
+ /** Seconds between refreshes of the flag definitions. */
8
+ refreshInterval: number;
9
+ /** Seconds an evaluation waits for the first definitions before it fails. */
10
+ readyTimeout: number;
11
+ }
12
+ /**
13
+ * The one Unleash client of this server process. It keeps the flag definitions in memory and evaluates every call
14
+ * against the context passed to that call, so concurrent requests of different people never share state.
15
+ *
16
+ * Definitions are in memory from the first fetch, or from the backup file of an earlier run when the server is
17
+ * unreachable at start — those are real last-known flags, not a guess.
18
+ */
19
+ export declare function startUnleash(config: UnleashServerConfig): Unleash;
20
+ /** The client once its definitions are in memory, for server routes that evaluate flags themselves. */
21
+ export declare function useUnleashClient(config: UnleashServerConfig): Promise<Unleash>;
22
+ /** Every flag defined for this `appName`, evaluated for one context. */
23
+ export declare function evaluateFlags(config: UnleashServerConfig, context: UnleashContext): Promise<FeatureFlagsEvaluation>;
@@ -0,0 +1,52 @@
1
+ import { Unleash, UnleashEvents } from "unleash-client";
2
+ import { FeatureFlagsUnavailable } from "../types.js";
3
+ let client;
4
+ let startup;
5
+ let hasDefinitions = false;
6
+ function requireSetting(config, key) {
7
+ const value = config[key];
8
+ if (!value) throw new FeatureFlagsUnavailable(`runtimeConfig.unleash.${key} is not set`);
9
+ return value;
10
+ }
11
+ export function startUnleash(config) {
12
+ if (client) return client;
13
+ client = new Unleash({
14
+ url: requireSetting(config, "url"),
15
+ instanceId: requireSetting(config, "instanceId"),
16
+ appName: requireSetting(config, "appName"),
17
+ refreshInterval: config.refreshInterval * 1e3,
18
+ // GitLab accepts client metrics and drops them (lib/api/unleash.rb: "not supported yet").
19
+ disableMetrics: true
20
+ });
21
+ const started = client;
22
+ if (started.isSynchronized()) hasDefinitions = true;
23
+ started.once(UnleashEvents.Ready, () => {
24
+ hasDefinitions = true;
25
+ });
26
+ startup = new Promise((resolve) => {
27
+ if (hasDefinitions) return resolve();
28
+ const timer = setTimeout(resolve, config.readyTimeout * 1e3);
29
+ timer.unref();
30
+ started.once(UnleashEvents.Ready, () => {
31
+ clearTimeout(timer);
32
+ resolve();
33
+ });
34
+ });
35
+ client.on(UnleashEvents.Error, (error) => console.error("[unleash]", error));
36
+ client.on(UnleashEvents.Warn, (message) => console.warn("[unleash]", message));
37
+ return client;
38
+ }
39
+ async function readyClient(config) {
40
+ const unleash = startUnleash(config);
41
+ await startup;
42
+ if (!hasDefinitions) throw new FeatureFlagsUnavailable("no flag definitions since the server started");
43
+ return unleash;
44
+ }
45
+ export function useUnleashClient(config) {
46
+ return readyClient(config);
47
+ }
48
+ export async function evaluateFlags(config, context) {
49
+ const unleash = await readyClient(config);
50
+ const enabled = unleash.getFeatureToggleDefinitions().map((definition) => definition.name).filter((name) => unleash.isEnabled(name, context));
51
+ return { enabled };
52
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Unleash context of one evaluation. The application fills it (see `useUnleashContext`); the module adds nothing
3
+ * of its own. `appName` and `environment` are not part of it: GitLab picks the environment from the client's
4
+ * `appName`, which is fixed per deployment in the module options.
5
+ */
6
+ export type UnleashContext = {
7
+ userId?: string;
8
+ sessionId?: string;
9
+ remoteAddress?: string;
10
+ properties?: Record<string, string>;
11
+ };
12
+ /** Names of the flags that are on for a context. */
13
+ export interface FeatureFlagsEvaluation {
14
+ enabled: string[];
15
+ }
16
+ /**
17
+ * What the server hands the Nuxt app for one context. The app and Nitro are separate bundles with their own copy of
18
+ * every class, so a failure crosses as data — an `instanceof` check on a thrown error would never match.
19
+ */
20
+ export type FeatureFlagsOutcome = FeatureFlagsEvaluation | {
21
+ unavailable: string;
22
+ };
23
+ /** Flag definitions were not available in time, or the browser could not reach the module's endpoint. */
24
+ export declare class FeatureFlagsUnavailable extends Error {
25
+ readonly reason: string;
26
+ constructor(reason: string, options?: ErrorOptions);
27
+ }
@@ -0,0 +1,8 @@
1
+ export class FeatureFlagsUnavailable extends Error {
2
+ reason;
3
+ constructor(reason, options) {
4
+ super(`Feature flags are unavailable: ${reason}`, options);
5
+ this.reason = reason;
6
+ this.name = "FeatureFlagsUnavailable";
7
+ }
8
+ }
@@ -0,0 +1 @@
1
+ export { type FeatureFlagsEvaluation, type FeatureFlagsUnavailable, type UnleashContext } from './module.js'
@@ -0,0 +1 @@
1
+ export { type FeatureFlagsEvaluation, type FeatureFlagsUnavailable, type UnleashContext } from './module'
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@approck/nuxt-unleash",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Unleash and GitLab feature flags for Nuxt, evaluated on the server and delivered with the page.",
5
+ "repository": "https://github.com/adalekin/nuxt-unleash",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "private": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types.d.ts",
12
+ "import": "./dist/module.mjs",
13
+ "require": "./dist/module.cjs"
14
+ }
15
+ },
16
+ "main": "./dist/module.cjs",
17
+ "types": "./dist/types.d.ts",
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "prepack": "nuxt-module-build prepare && nuxt-module-build build",
23
+ "dev": "nuxi dev playground",
24
+ "dev:build": "nuxi build playground",
25
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
26
+ "release": "npm run lint && npm run test && changelogen --release && git push --follow-tags",
27
+ "release-temp": "npm run prepack && npm publish --access=public",
28
+ "lint": "eslint .",
29
+ "lint:fix": "eslint . --fix",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest watch",
32
+ "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@nuxt/kit": "^3.14.1592",
36
+ "defu": "^6.1.4",
37
+ "ofetch": "^1.4.1",
38
+ "unleash-client": "^6.12.1"
39
+ },
40
+ "devDependencies": {
41
+ "@nuxt/devtools": "^1.6.1",
42
+ "@nuxt/eslint-config": "^0.6.2",
43
+ "@nuxt/module-builder": "^0.8.4",
44
+ "@nuxt/schema": "^3.14.1592",
45
+ "@nuxt/test-utils": "^3.14.4",
46
+ "@types/node": "latest",
47
+ "changelogen": "^0.5.7",
48
+ "eslint": "^9.15.0",
49
+ "nuxt": "^3.14.1592",
50
+ "typescript": "latest",
51
+ "vitest": "^2.1.6",
52
+ "vue-tsc": "^2.1.10"
53
+ }
6
54
  }