@hostlink/nuxt-light 1.77.2 → 1.78.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/README.md CHANGED
@@ -79,6 +79,9 @@ export default defineNuxtConfig({
79
79
  infra: {
80
80
  baseURL: 'https://infra.example.com/graphql',
81
81
  audience: 'infra-api',
82
+ healthURL: 'https://infra.example.com/health',
83
+ healthTimeout: 3000,
84
+ unavailableMessage: 'Connect to the company network or VPN.',
82
85
  },
83
86
  },
84
87
  },
@@ -113,6 +116,38 @@ For example, the first request made by this table automatically requests a
113
116
 
114
117
  ```vue
115
118
  <L-Table client="business" model-name="Order" />
119
+
120
+
121
+ ### Client availability and menus
122
+
123
+ When a client has a `healthURL`, nuxt-light checks it on startup, when the
124
+ browser comes back online, and when the tab becomes visible. Menu items can
125
+ declare that they require that client:
126
+
127
+
128
+ ```yaml
129
+ - label: Servers
130
+ icon: sym_o_dns
131
+ to: /Server
132
+ permission: server.list
133
+ requiresClient: infra
134
+ ```
135
+
136
+
137
+ `L-Menu` keeps the authorized item visible, but disables it and shows the
138
+ configured message while the client is checking or unavailable. Applications
139
+ can use the same state for route guards or page notices:
140
+
141
+
142
+ ```ts
143
+ const availability = useLightClientAvailability()
144
+
145
+ if (!availability.isAvailable('infra')) {
146
+ // Show a company-network/VPN notice.
147
+ }
148
+ ```
149
+
150
+ Clients without a `healthURL` remain available and keep the previous behavior.
116
151
  ```
117
152
 
118
153
  ### Backwards-compatible single endpoint
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "light",
3
3
  "configKey": "light",
4
- "version": "1.77.2",
4
+ "version": "1.78.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -2,8 +2,18 @@
2
2
  import { useRoute } from "vue-router";
3
3
  import { ref, computed } from "vue";
4
4
  import { useLight } from "#imports";
5
+ import useLightClientAvailability from "../../composables/useLightClientAvailability";
5
6
  const props = defineProps(["value", "dense", "expandAll"]);
6
7
  const light = useLight();
8
+ const availability = useLightClientAvailability();
9
+ const requiredClient = (menu) => typeof menu?.requiresClient === "string" ? menu.requiresClient : void 0;
10
+ const isMenuAvailable = (menu) => availability.isAvailable(requiredClient(menu));
11
+ const availabilityMessage = (menu) => {
12
+ const client = requiredClient(menu);
13
+ if (!client) return "";
14
+ if (availability.isChecking(client)) return "Checking service availability\u2026";
15
+ return availability.message(client);
16
+ };
7
17
  const isShowExpansionItem = (menu) => {
8
18
  if (menu.children && menu.children.length > 0) {
9
19
  return true;
@@ -48,6 +58,7 @@ const hasChildLink = () => {
48
58
  <template v-for="menu in value">
49
59
 
50
60
  <q-expansion-item :default-opened="expandAll || hasLink(menu)" :label="$t(menu.label)" :icon="menu.icon" :dense="dense"
61
+ :disable="!isMenuAvailable(menu)" :caption="!isMenuAvailable(menu) ? availabilityMessage(menu) : undefined"
51
62
  v-if="isShowExpansionItem(menu)" :group="expandAll ? undefined : group">
52
63
  <l-menu class="q-pl-md" :value="menu.children" :dense="dense" :expand-all="expandAll"></l-menu>
53
64
  </q-expansion-item>
@@ -56,20 +67,34 @@ const hasChildLink = () => {
56
67
  <q-separator v-if="menu.type == 'separator'" :spaced="menu.spaced" />
57
68
  <q-item-label header v-if="menu.type == 'header'">{{ menu.label }}</q-item-label>
58
69
  <template v-if="menu.to && (menu.to.startsWith('http://') || menu.to.startsWith('https://'))">
59
- <q-item v-ripple :href="menu.to" v-if="!value.type" target="_blank">
70
+ <q-item v-ripple :href="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type" target="_blank"
71
+ :disable="!isMenuAvailable(menu)">
60
72
  <q-item-section avatar>
61
73
  <q-icon :name="menu.icon" />
62
74
  </q-item-section>
63
- <q-item-section>{{ $t(menu.label) }}</q-item-section>
75
+ <q-item-section>
76
+ {{ $t(menu.label) }}
77
+ <q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
78
+ </q-item-section>
79
+ <q-item-section v-if="!isMenuAvailable(menu)" side>
80
+ <q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
81
+ </q-item-section>
64
82
  </q-item>
65
83
  </template>
66
84
 
67
85
  <template v-else>
68
- <q-item v-ripple :to="menu.to" v-if="!value.type">
86
+ <q-item v-ripple :to="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type"
87
+ :disable="!isMenuAvailable(menu)">
69
88
  <q-item-section avatar>
70
89
  <q-icon :name="menu.icon" />
71
90
  </q-item-section>
72
- <q-item-section>{{ $t(menu.label) }}</q-item-section>
91
+ <q-item-section>
92
+ {{ $t(menu.label) }}
93
+ <q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
94
+ </q-item-section>
95
+ <q-item-section v-if="!isMenuAvailable(menu)" side>
96
+ <q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
97
+ </q-item-section>
73
98
  </q-item>
74
99
  </template>
75
100
 
@@ -0,0 +1,30 @@
1
+ export type LightClientAvailabilityStatus = 'checking' | 'available' | 'unavailable';
2
+ export interface LightClientAvailabilityOptions {
3
+ healthURL?: string;
4
+ healthTimeout?: number;
5
+ unavailableMessage?: string;
6
+ }
7
+ export interface LightClientAvailabilityState {
8
+ status: LightClientAvailabilityStatus;
9
+ message: string;
10
+ checkedAt: number | null;
11
+ }
12
+ export declare function registerLightClientAvailability(name: string, config?: LightClientAvailabilityOptions): void;
13
+ export declare function clearLightClientAvailability(): void;
14
+ export declare function checkLightClientAvailability(name: string): Promise<LightClientAvailabilityStatus>;
15
+ export declare function checkAllLightClientAvailability(): Promise<void>;
16
+ export default function useLightClientAvailability(): {
17
+ states: {
18
+ readonly [x: string]: {
19
+ readonly status: LightClientAvailabilityStatus;
20
+ readonly message: string;
21
+ readonly checkedAt: number | null;
22
+ };
23
+ };
24
+ status: (name?: string) => LightClientAvailabilityStatus;
25
+ isAvailable: (name?: string) => boolean;
26
+ isChecking: (name?: string) => boolean;
27
+ message: (name?: string) => string;
28
+ check: typeof checkLightClientAvailability;
29
+ checkAll: typeof checkAllLightClientAvailability;
30
+ };
@@ -0,0 +1,88 @@
1
+ import { reactive, readonly } from "vue";
2
+ const DEFAULT_TIMEOUT = 3e3;
3
+ const DEFAULT_MESSAGE = "This service is currently unavailable.";
4
+ const options = /* @__PURE__ */ new Map();
5
+ const checks = /* @__PURE__ */ new Map();
6
+ const states = reactive({});
7
+ export function registerLightClientAvailability(name, config = {}) {
8
+ options.set(name, config);
9
+ states[name] = {
10
+ status: config.healthURL ? "checking" : "available",
11
+ message: "",
12
+ checkedAt: null
13
+ };
14
+ }
15
+ export function clearLightClientAvailability() {
16
+ options.clear();
17
+ checks.clear();
18
+ Object.keys(states).forEach((name) => delete states[name]);
19
+ }
20
+ export async function checkLightClientAvailability(name) {
21
+ const config = options.get(name);
22
+ if (!config) {
23
+ states[name] = {
24
+ status: "unavailable",
25
+ message: `Light client "${name}" is not configured.`,
26
+ checkedAt: Date.now()
27
+ };
28
+ return "unavailable";
29
+ }
30
+ if (!config.healthURL) {
31
+ states[name] = { status: "available", message: "", checkedAt: Date.now() };
32
+ return "available";
33
+ }
34
+ const checkId = (checks.get(name) ?? 0) + 1;
35
+ checks.set(name, checkId);
36
+ states[name] = { ...states[name], status: "checking", message: "" };
37
+ const controller = new AbortController();
38
+ const timeout = globalThis.setTimeout(
39
+ () => controller.abort(),
40
+ Math.max(1, config.healthTimeout ?? DEFAULT_TIMEOUT)
41
+ );
42
+ try {
43
+ const response = await fetch(config.healthURL, {
44
+ method: "GET",
45
+ cache: "no-store",
46
+ credentials: "omit",
47
+ signal: controller.signal
48
+ });
49
+ const status = response.ok ? "available" : "unavailable";
50
+ if (checks.get(name) === checkId) {
51
+ states[name] = {
52
+ status,
53
+ message: response.ok ? "" : config.unavailableMessage ?? DEFAULT_MESSAGE,
54
+ checkedAt: Date.now()
55
+ };
56
+ }
57
+ return status;
58
+ } catch {
59
+ if (checks.get(name) === checkId) {
60
+ states[name] = {
61
+ status: "unavailable",
62
+ message: config.unavailableMessage ?? DEFAULT_MESSAGE,
63
+ checkedAt: Date.now()
64
+ };
65
+ }
66
+ return "unavailable";
67
+ } finally {
68
+ globalThis.clearTimeout(timeout);
69
+ }
70
+ }
71
+ export async function checkAllLightClientAvailability() {
72
+ await Promise.all([...options.keys()].map((name) => checkLightClientAvailability(name)));
73
+ }
74
+ export default function useLightClientAvailability() {
75
+ const status = (name) => {
76
+ if (!name) return "available";
77
+ return states[name]?.status ?? "unavailable";
78
+ };
79
+ return {
80
+ states: readonly(states),
81
+ status,
82
+ isAvailable: (name) => status(name) === "available",
83
+ isChecking: (name) => status(name) === "checking",
84
+ message: (name) => name ? states[name]?.message ?? `Light client "${name}" is not configured.` : "",
85
+ check: checkLightClientAvailability,
86
+ checkAll: checkAllLightClientAvailability
87
+ };
88
+ }
@@ -13,19 +13,28 @@ import { plugin, defaultConfig } from "@formkit/vue";
13
13
  import getApiBase from "./composables/getApiBase.js";
14
14
  import useLight from "./composables/useLight.js";
15
15
  import useLightClient, { clearLightClients, registerLightClient } from "./composables/useLightClient.js";
16
+ import {
17
+ checkAllLightClientAvailability,
18
+ checkLightClientAvailability,
19
+ clearLightClientAvailability,
20
+ registerLightClientAvailability
21
+ } from "./composables/useLightClientAvailability.js";
16
22
  import { zhTW } from "@formkit/i18n";
17
23
  export default defineNuxtPlugin((nuxtApp) => {
18
24
  const runtimeConfig = useRuntimeConfig();
19
25
  const lightConfig = runtimeConfig.public.light;
20
26
  const configuredClients = lightConfig?.clients ?? {};
21
27
  clearLightClients();
28
+ clearLightClientAvailability();
22
29
  for (const [name, config] of Object.entries(configuredClients)) {
23
30
  const baseURL = typeof config === "string" ? config : config.baseURL;
24
31
  if (!baseURL) throw new Error(`Light client "${name}" requires a baseURL`);
25
32
  registerLightClient(name, createClient(baseURL));
33
+ registerLightClientAvailability(name, typeof config === "string" ? {} : config);
26
34
  }
27
35
  if (!configuredClients.auth) {
28
36
  registerLightClient("auth", createClient(getApiBase()));
37
+ registerLightClientAvailability("auth");
29
38
  }
30
39
  const authClient = useLightClient();
31
40
  setApiClient(authClient);
@@ -33,6 +42,21 @@ export default defineNuxtPlugin((nuxtApp) => {
33
42
  if (name === "auth" || typeof config === "string" || !config.audience) continue;
34
43
  useLightClient(name).useAudience(authClient, config.audience);
35
44
  }
45
+ if (import.meta.client) {
46
+ for (const name of Object.keys(configuredClients)) {
47
+ void checkLightClientAvailability(name);
48
+ }
49
+ const recheckClients = () => void checkAllLightClientAvailability();
50
+ const recheckVisibleClients = () => {
51
+ if (document.visibilityState === "visible") recheckClients();
52
+ };
53
+ window.addEventListener("online", recheckClients);
54
+ document.addEventListener("visibilitychange", recheckVisibleClients);
55
+ nuxtApp.hook("app:unmounted", () => {
56
+ window.removeEventListener("online", recheckClients);
57
+ document.removeEventListener("visibilitychange", recheckVisibleClients);
58
+ });
59
+ }
36
60
  defineModel("Permission", {}).setDataPath("app.listPermission");
37
61
  defineModel("SystemValue", {}).setDataPath("app.listSystemValue");
38
62
  defineModel("Config", {}).setDataPath("app.listConfig");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostlink/nuxt-light",
3
- "version": "1.77.2",
3
+ "version": "1.78.0",
4
4
  "description": "HostLink Nuxt Light Framework",
5
5
  "repository": {
6
6
  "type": "git",