create-nexwire 4.2.4 → 4.3.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.
Files changed (113) hide show
  1. package/README.md +20 -8
  2. package/express/.env +1 -1
  3. package/express/.vscode/extensions.json +6 -0
  4. package/express/.vscode/settings.json +37 -26
  5. package/express/biome.json +99 -66
  6. package/express/package.json +31 -29
  7. package/express/src/config/database.ts +48 -29
  8. package/express/src/config/index.ts +83 -83
  9. package/express/src/config/openapi.ts +6 -24
  10. package/express/src/config/queue.ts +30 -30
  11. package/express/src/config/validate.ts +50 -40
  12. package/express/src/database/migrations/sqlite/0000_init.sql +47 -47
  13. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  14. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  15. package/express/src/database/schema.ts +2 -2
  16. package/express/src/framework/cache/cache.ts +40 -8
  17. package/express/src/framework/circuit-breaker/cb.ts +88 -60
  18. package/express/src/framework/database/connection.ts +52 -27
  19. package/express/src/framework/events/dispatcher.ts +61 -49
  20. package/express/src/framework/facade.ts +46 -3
  21. package/express/src/framework/http/app.ts +28 -22
  22. package/express/src/framework/http/logger.ts +27 -16
  23. package/express/src/framework/http/openapi.ts +9 -11
  24. package/express/src/framework/modules/routes.ts +26 -21
  25. package/express/src/framework/notification/channels/broadcast.ts +15 -0
  26. package/express/src/framework/notification/channels/mail.ts +40 -0
  27. package/express/src/framework/notification/contracts.ts +29 -0
  28. package/express/src/framework/notification/index.ts +19 -94
  29. package/express/src/framework/notification/registry.ts +15 -0
  30. package/express/src/framework/providers/adapter.ts +27 -17
  31. package/express/src/framework/queue/queue.ts +43 -44
  32. package/express/src/framework/realtime/broadcast.ts +29 -5
  33. package/express/src/framework/realtime/socket.ts +149 -119
  34. package/express/src/framework/redis/client.ts +148 -151
  35. package/express/src/framework/runtime/server-adapter.ts +39 -34
  36. package/express/src/framework/scheduler/lock.ts +128 -116
  37. package/express/src/framework/server.ts +33 -49
  38. package/express/src/framework/session/session.ts +69 -14
  39. package/express/src/framework/storage/storage.ts +744 -785
  40. package/express/src/framework/support/mail.ts +15 -7
  41. package/express/src/providers/notification.ts +4 -0
  42. package/express/src/resources/src/components/Checkbox.vue +32 -29
  43. package/express/src/resources/src/components/Input.vue +243 -223
  44. package/express/src/resources/src/components/InputGroup.vue +147 -145
  45. package/express/src/resources/src/components/InputPasswordToggle.vue +229 -213
  46. package/express/src/resources/src/components/Modal.vue +71 -55
  47. package/express/src/resources/src/components/Refresh.vue +19 -0
  48. package/express/src/resources/src/helpers/assets.ts +51 -0
  49. package/express/src/resources/src/layouts/Layout/Header.vue +124 -105
  50. package/express/src/resources/src/main.ts +3 -1
  51. package/express/src/resources/src/plugins/gum.ts +1 -1
  52. package/express/src/resources/src/plugins/pulse.ts +157 -147
  53. package/express/src/resources/src/stores/auth.ts +184 -164
  54. package/express/src/resources/vite.config.ts +54 -36
  55. package/express/src/storage/database/nexwire.sqlite +0 -0
  56. package/express/src/storage/logs/app.log +0 -0
  57. package/express/src/storage/logs/fatal.log +0 -0
  58. package/express/src/types/global.d.ts +32 -26
  59. package/hono/.env +1 -1
  60. package/hono/.vscode/extensions.json +6 -0
  61. package/hono/.vscode/settings.json +37 -26
  62. package/hono/biome.json +25 -2
  63. package/hono/package.json +25 -23
  64. package/hono/src/config/database.ts +48 -29
  65. package/hono/src/config/index.ts +83 -83
  66. package/hono/src/config/openapi.ts +6 -24
  67. package/hono/src/config/queue.ts +30 -30
  68. package/hono/src/config/validate.ts +50 -40
  69. package/hono/src/database/migrations/sqlite/0000_init.sql +47 -47
  70. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  71. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  72. package/hono/src/database/schema.ts +2 -2
  73. package/hono/src/framework/cache/cache.ts +40 -8
  74. package/hono/src/framework/circuit-breaker/cb.ts +88 -60
  75. package/hono/src/framework/database/connection.ts +52 -27
  76. package/hono/src/framework/events/dispatcher.ts +61 -49
  77. package/hono/src/framework/facade.ts +38 -1
  78. package/hono/src/framework/http/openapi.ts +7 -9
  79. package/hono/src/framework/notification/channels/broadcast.ts +15 -0
  80. package/hono/src/framework/notification/channels/mail.ts +40 -0
  81. package/hono/src/framework/notification/contracts.ts +29 -0
  82. package/hono/src/framework/notification/index.ts +19 -94
  83. package/hono/src/framework/notification/registry.ts +15 -0
  84. package/hono/src/framework/providers/adapter.ts +27 -17
  85. package/hono/src/framework/queue/queue.ts +43 -44
  86. package/hono/src/framework/realtime/broadcast.ts +29 -5
  87. package/hono/src/framework/realtime/socket.ts +217 -196
  88. package/hono/src/framework/redis/client.ts +148 -151
  89. package/hono/src/framework/scheduler/lock.ts +128 -116
  90. package/hono/src/framework/server.ts +27 -29
  91. package/hono/src/framework/session/session.ts +62 -14
  92. package/hono/src/framework/storage/storage.ts +744 -785
  93. package/hono/src/framework/support/mail.ts +56 -48
  94. package/hono/src/providers/notification.ts +4 -0
  95. package/hono/src/resources/src/components/Checkbox.vue +32 -29
  96. package/hono/src/resources/src/components/Input.vue +243 -234
  97. package/hono/src/resources/src/components/InputGroup.vue +147 -149
  98. package/hono/src/resources/src/components/InputPasswordToggle.vue +229 -222
  99. package/hono/src/resources/src/components/Modal.vue +71 -55
  100. package/hono/src/resources/src/components/Refresh.vue +19 -0
  101. package/hono/src/resources/src/helpers/assets.ts +51 -0
  102. package/hono/src/resources/src/layouts/Layout/Header.vue +124 -105
  103. package/hono/src/resources/src/main.ts +3 -1
  104. package/hono/src/resources/src/plugins/gum.ts +1 -1
  105. package/hono/src/resources/src/plugins/pulse.ts +157 -147
  106. package/hono/src/resources/src/stores/auth.ts +184 -164
  107. package/hono/src/resources/vite.config.ts +54 -36
  108. package/hono/src/storage/database/nexwire.sqlite +0 -0
  109. package/hono/src/storage/logs/app.log +0 -0
  110. package/hono/src/storage/logs/fatal.log +0 -0
  111. package/hono/src/types/global.d.ts +32 -26
  112. package/package.json +2 -2
  113. package/src/index.js +139 -14
@@ -0,0 +1,51 @@
1
+ // src/helpers/assets.ts
2
+
3
+ // 1. Eagerly load ALL images in the entire src folder.
4
+ // This tells Vite: "Find every image, hash it, and give me the final URL."
5
+ const allImages = import.meta.glob("/src/**/*.{png,jpg,jpeg,svg,webp,gif}", {
6
+ eager: true,
7
+ import: "default"
8
+ });
9
+
10
+ /**
11
+ * Resolves ANY image path (using @, relative, or absolute) for both dev and prod.
12
+ */
13
+ export default function asset(path: string): string {
14
+ // 1. If it's already a full URL or data URI, return it as is.
15
+ if (path.startsWith("http") || path.startsWith("data:")) {
16
+ return path;
17
+ }
18
+
19
+ // 2. Normalize the path.
20
+ // Vite's glob keys always start from the root, like "/src/assets/..."
21
+ let cleanPath = path;
22
+
23
+ // Convert alias `@/` to `/src/`
24
+ if (cleanPath.startsWith("@/")) {
25
+ cleanPath = cleanPath.replace("@/", "/src/");
26
+ }
27
+ // Convert absolute `/` to `/src/` (assuming you mean inside src)
28
+ else if (cleanPath.startsWith("/") && !cleanPath.startsWith("/src/")) {
29
+ cleanPath = `/src${cleanPath}`;
30
+ }
31
+ // Convert relative `../../` (This is the tricky part)
32
+ else if (cleanPath.startsWith("../")) {
33
+ // We can't easily resolve `../../` from a utility file, so we just
34
+ // try to find a matching suffix in the glob keys.
35
+ // (e.g., if you pass "../../assets/foo.png", we look for "assets/foo.png" inside /src)
36
+ const match = Object.keys(allImages).find((key) => key.endsWith(cleanPath.replace(/^(\.\.\/)+/, "")));
37
+ if (match) {
38
+ return allImages[match] as string;
39
+ }
40
+ }
41
+
42
+ // 3. Look up the normalized path in the glob object
43
+ const resolved = allImages[cleanPath] as string | undefined;
44
+
45
+ if (!resolved) {
46
+ console.warn(`[getAssetUrl] Image not found: ${path}. Returning original path.`);
47
+ return path;
48
+ }
49
+
50
+ return resolved;
51
+ }
@@ -1,105 +1,124 @@
1
- <template>
2
- <header :class="{ scrolled: isScrolled }">
3
- <nav class="navbar bg-transparent p-0">
4
- <div class="container-fluid p-0 row-gap-2 column-gap-1">
5
- <button
6
- id="off-canvas"
7
- type="button"
8
- class="nav-btn order-0"
9
- title="Toggle Button"
10
- @click="props.onToggleSidebar">
11
- <i class="bi bi-layout-text-sidebar-reverse"></i>
12
- </button>
13
- <div id="pagebar" class="card card-body nav-card order-5 order-sm-2"></div>
14
- <template v-for="btn in featureButtons" :key="btn.id">
15
- <button v-if="!btn.render" type="button" class="nav-btn order-3 order-sm-3" :class="btn.class" :title="btn.title || undefined" :aria-label="btn.label || 'button'" v-bind="btn.attrs" @click="btn.onClick">
16
- <i v-if="btn.icon" :class="btn.icon"></i>
17
- <span v-if="btn.label" class="ms-1">{{ btn.label }}</span>
18
- </button>
19
- <template v-else>
20
- <component :is="featureComponent(btn)" />
21
- </template>
22
- </template>
23
- <div class="btn-group order-4 order-sm-4">
24
- <button type="button" class="nav-btn w-100" title="user" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false" aria-label="button">
25
- <i class="bi bi-person-circle"></i>
26
- </button>
27
- <ul class="dropdown-menu dropdown-menu-end">
28
- <li>
29
- <a class="dropdown-item" href="#">
30
- <div class="d-flex align-items-center">
31
- <div class="flex-shrink-0 me-3">
32
- <i class="bi bi-person-circle fs-1"></i>
33
- </div>
34
- <div class="flex-grow-1 text-capitalize">
35
- <h6 class="mb-0">{{ authUser?.name }}</h6>
36
- <small class="text-muted">{{ authUser.role?.name }}</small>
37
- </div>
38
- </div>
39
- </a>
40
- </li>
41
- <li>
42
- <div class="dropdown-divider my-1"></div>
43
- </li>
44
- <li>
45
- <router-link class="dropdown-item" to="/profile">
46
- <i class="bi bi-person-check me-3"></i><span>My Profile</span>
47
- </router-link>
48
- </li>
49
- <li>
50
- <button type="button" class="dropdown-item" @click="props.onLogout">
51
- <i class="bi bi-power me-3"></i><span>Log Out</span>
52
- </button>
53
- </li>
54
- </ul>
55
- </div>
56
- <button id="theme-switch" type="button" title="theme" class="nav-btn order-1 order-sm-5" aria-label="button" @click="props.onToggleTheme">
57
- <i :class="themeIconClass"></i>
58
- </button>
59
- </div>
60
- </nav>
61
- </header>
62
- </template>
63
-
64
- <script setup lang="ts">
65
- import { computed, h, inject } from "vue";
66
- import { authUser } from "@/composables/useAuth";
67
-
68
- interface ButtonEntry {
69
- id: symbol;
70
- icon?: string;
71
- label: string;
72
- title: string;
73
- attrs?: Record<string, string>;
74
- class?: any;
75
- onClick: () => void;
76
- render?: () => any[];
77
- }
78
-
79
- const featureButtons = inject<ButtonEntry[]>("featureButtons")!;
80
-
81
- function featureComponent(btn: ButtonEntry) {
82
- return {
83
- name: "FeatureSlot",
84
- render: () => h("div", { class: ["order-3 order-sm-4", btn.class] }, btn.render!())
85
- };
86
- }
87
-
88
- interface HeaderProps {
89
- isScrolled: boolean;
90
- themeMode: "light" | "dark" | "auto";
91
- onLogout: () => void | Promise<void>;
92
- onToggleSidebar: () => void;
93
- onToggleTheme: () => void;
94
- }
95
-
96
- const props = defineProps<HeaderProps>();
97
-
98
- const themeIconClass = computed(() => {
99
- if (props.themeMode === "light") return "bi bi-brightness-high-fill";
100
- if (props.themeMode === "dark") return "bi bi-moon-stars";
101
- return "bi bi-circle-half";
102
- });
103
- </script>
104
-
105
- <style lang="scss" scoped></style>
1
+ <template>
2
+ <header :class="{ scrolled: isScrolled }">
3
+ <nav class="navbar bg-transparent p-0">
4
+ <div class="container-fluid p-0 row-gap-2 column-gap-1">
5
+ <button id="off-canvas" type="button" class="nav-btn order-0" title="Toggle Button" @click="props.onToggleSidebar">
6
+ <i class="bi bi-layout-text-sidebar-reverse"></i>
7
+ </button>
8
+ <div id="pagebar" class="card card-body nav-card order-5 order-sm-2"></div>
9
+ <template v-for="btn in featureButtons" :key="btn.id">
10
+ <button
11
+ v-if="!btn.render"
12
+ type="button"
13
+ class="nav-btn order-3 order-sm-3"
14
+ :class="btn.class"
15
+ :title="btn.title || undefined"
16
+ :aria-label="btn.label || 'button'"
17
+ v-bind="btn.attrs"
18
+ @click="btn.onClick"
19
+ >
20
+ <i v-if="btn.icon" :class="btn.icon"></i>
21
+ <span v-if="btn.label" class="ms-1">{{ btn.label }}</span>
22
+ </button>
23
+ <template v-else>
24
+ <component :is="featureComponent(btn)" />
25
+ </template>
26
+ </template>
27
+ <div class="btn-group order-4 order-sm-4">
28
+ <button
29
+ type="button"
30
+ class="nav-btn w-100"
31
+ title="user"
32
+ data-bs-toggle="dropdown"
33
+ data-bs-display="static"
34
+ aria-expanded="false"
35
+ aria-label="button"
36
+ >
37
+ <i class="bi bi-person-circle"></i>
38
+ </button>
39
+ <ul class="dropdown-menu dropdown-menu-end">
40
+ <li>
41
+ <a class="dropdown-item" href="#">
42
+ <div class="d-flex align-items-center">
43
+ <div class="flex-shrink-0 me-3">
44
+ <i class="bi bi-person-circle fs-1"></i>
45
+ </div>
46
+ <div class="flex-grow-1 text-capitalize">
47
+ <h6 class="mb-0">{{ authUser?.name }}</h6>
48
+ <small class="text-muted">{{ authUser.role?.name }}</small>
49
+ </div>
50
+ </div>
51
+ </a>
52
+ </li>
53
+ <li>
54
+ <div class="dropdown-divider my-1"></div>
55
+ </li>
56
+ <li>
57
+ <router-link class="dropdown-item" to="#">
58
+ <i class="bi bi-person-check me-3"></i><span>My Profile</span>
59
+ </router-link>
60
+ </li>
61
+ <li>
62
+ <button type="button" class="dropdown-item" @click="props.onLogout">
63
+ <i class="bi bi-power me-3"></i><span>Log Out</span>
64
+ </button>
65
+ </li>
66
+ </ul>
67
+ </div>
68
+ <button
69
+ id="theme-switch"
70
+ type="button"
71
+ title="theme"
72
+ class="nav-btn order-1 order-sm-5"
73
+ aria-label="button"
74
+ @click="props.onToggleTheme"
75
+ >
76
+ <i :class="themeIconClass"></i>
77
+ </button>
78
+ </div>
79
+ </nav>
80
+ </header>
81
+ </template>
82
+
83
+ <script setup lang="ts">
84
+ import { computed, h, inject } from "vue";
85
+ import { authUser } from "@/composables/useAuth";
86
+
87
+ interface ButtonEntry {
88
+ id: symbol;
89
+ icon?: string;
90
+ label: string;
91
+ title: string;
92
+ attrs?: Record<string, string>;
93
+ class?: any;
94
+ onClick: () => void;
95
+ render?: () => any[];
96
+ }
97
+
98
+ const featureButtons = inject<ButtonEntry[]>("featureButtons")!;
99
+
100
+ function featureComponent(btn: ButtonEntry) {
101
+ return {
102
+ name: "FeatureSlot",
103
+ render: () => h("div", { class: ["order-3 order-sm-4", btn.class] }, btn.render!())
104
+ };
105
+ }
106
+
107
+ interface HeaderProps {
108
+ isScrolled: boolean;
109
+ themeMode: "light" | "dark" | "auto";
110
+ onLogout: () => void | Promise<void>;
111
+ onToggleSidebar: () => void;
112
+ onToggleTheme: () => void;
113
+ }
114
+
115
+ const props = defineProps<HeaderProps>();
116
+
117
+ const themeIconClass = computed(() => {
118
+ if (props.themeMode === "light") return "bi bi-brightness-high-fill";
119
+ if (props.themeMode === "dark") return "bi bi-moon-stars";
120
+ return "bi bi-circle-half";
121
+ });
122
+ </script>
123
+
124
+ <style lang="scss" scoped></style>
@@ -15,7 +15,9 @@ import "bootstrap/dist/js/bootstrap.bundle.min.js";
15
15
 
16
16
  const app = createApp(App);
17
17
  const pinia = createPinia();
18
- const head = createHead({ titleTemplate: (title) => (title ? `${title} | nexwire` : "nexwire") });
18
+ const head = createHead({
19
+ init: [{ titleTemplate: (title) => (title ? `${title} | nexwire` : "nexwire") }],
20
+ });
19
21
 
20
22
  app.use(pinia);
21
23
  app.use(router);
@@ -50,7 +50,7 @@ type ValidationErrorPayload = {
50
50
  };
51
51
 
52
52
  function normalizeValidationErrors(payload?: ValidationErrorPayload): NormalizedErrors {
53
- const normalized: NormalizedErrors = { ...(payload?.errors || {}) };
53
+ const normalized: NormalizedErrors = { ...payload?.errors };
54
54
 
55
55
  const issues = payload?.error?.issues;
56
56
  if (Array.isArray(issues)) {
@@ -1,147 +1,157 @@
1
- import { io, type Socket } from "socket.io-client";
2
- import type { App } from "vue";
3
-
4
- declare const __SOCKET_ENABLED__: boolean;
5
-
6
- type EventCallback<T = unknown> = (payload: T) => void;
7
-
8
- type ChannelBinding = {
9
- event: string;
10
- callback: EventCallback;
11
- wrapped: EventCallback;
12
- };
13
-
14
- type PulseChannel = {
15
- listen: <T = unknown>(event: string, callback: EventCallback<T>) => PulseChannel;
16
- stopListening: (event: string, callback?: EventCallback) => PulseChannel;
17
- };
18
-
19
- export type PulseClient = {
20
- connect: () => PulseClient;
21
- disconnect: () => PulseClient;
22
- channel: (name: string) => PulseChannel;
23
- private: (room: string) => PulseChannel;
24
- leave: (room: string) => PulseClient;
25
- };
26
-
27
- function createPulse(): PulseClient {
28
- if (!__SOCKET_ENABLED__) {
29
- const n = (): PulseChannel => ({ listen: () => n(), stopListening: () => n() });
30
- const c: PulseClient = {
31
- connect: () => c,
32
- disconnect: () => c,
33
- channel: n,
34
- private: n,
35
- leave: () => c
36
- };
37
- return c;
38
- }
39
- let socket: Socket | null = null;
40
- const channels = new Map<string, ChannelBinding[]>();
41
- const joinedRooms = new Set<string>();
42
-
43
- const socketUrl = () => {
44
- const explicit = import.meta.env.VITE_SOCKET_URL || import.meta.env.VITE_API_URL;
45
- if (explicit) return explicit;
46
- return window.location.origin;
47
- };
48
-
49
- const ensureConnected = () => {
50
- if (socket) return socket;
51
-
52
- socket = io(socketUrl(), {
53
- withCredentials: true,
54
- transports: ["websocket", "polling"]
55
- });
56
-
57
- socket.on("connect", () => {
58
- for (const room of joinedRooms) {
59
- socket?.emit("join", room);
60
- }
61
- });
62
-
63
- return socket;
64
- };
65
-
66
- const channel = (name: string): PulseChannel => {
67
- const room = String(name).trim();
68
- if (!room) throw new Error("Channel name is required");
69
-
70
- const currentSocket = ensureConnected();
71
- joinedRooms.add(room);
72
- currentSocket.emit("join", room);
73
-
74
- const api: PulseChannel = {
75
- listen: <T = unknown>(event: string, callback: EventCallback<T>) => {
76
- const wrapped = (payload: unknown) => callback(payload as T);
77
- const bindings = channels.get(room) || [];
78
- bindings.push({ event, callback: callback as EventCallback, wrapped });
79
- channels.set(room, bindings);
80
- currentSocket.on(event, wrapped);
81
- return api;
82
- },
83
- stopListening: (event: string, callback?: EventCallback) => {
84
- const bindings = channels.get(room) || [];
85
- const kept: ChannelBinding[] = [];
86
-
87
- for (const binding of bindings) {
88
- const matchesEvent = binding.event === event;
89
- const matchesCallback = !callback || binding.callback === callback;
90
- if (matchesEvent && matchesCallback) {
91
- currentSocket.off(binding.event, binding.wrapped);
92
- } else {
93
- kept.push(binding);
94
- }
95
- }
96
-
97
- if (kept.length > 0) channels.set(room, kept);
98
- else channels.delete(room);
99
- return api;
100
- }
101
- };
102
-
103
- return api;
104
- };
105
-
106
- const pulse: PulseClient = {
107
- connect: () => {
108
- ensureConnected();
109
- return pulse;
110
- },
111
- disconnect: () => {
112
- socket?.disconnect();
113
- socket = null;
114
- return pulse;
115
- },
116
- channel,
117
- private: (room: string) => channel(`private:${room}`),
118
- leave: (room: string) => {
119
- if (!socket) return pulse;
120
-
121
- const bindings = channels.get(room) || [];
122
- for (const binding of bindings) {
123
- socket.off(binding.event, binding.wrapped);
124
- }
125
-
126
- channels.delete(room);
127
- joinedRooms.delete(room);
128
- return pulse;
129
- }
130
- };
131
-
132
- return pulse;
133
- }
134
-
135
- export const pulse = createPulse();
136
-
137
- export const PulsePlugin = {
138
- install(app: App) {
139
- app.config.globalProperties.$pulse = pulse;
140
- }
141
- };
142
-
143
- declare module "vue" {
144
- interface ComponentCustomProperties {
145
- $pulse: PulseClient;
146
- }
147
- }
1
+ import { io, type Socket } from "socket.io-client";
2
+ import type { App } from "vue";
3
+
4
+ // 1. Read the value Vite inlines into this module. Do not use a bare `define`
5
+ // global here: in dev it is a window global and can survive a server
6
+ // restart or browser cache, leaving Pulse enabled after SOCKET=false.
7
+ const isSocketEnabled = import.meta.env.VITE_SOCKET_ENABLED === true;
8
+
9
+ type EventCallback<T = unknown> = (payload: T) => void;
10
+
11
+ type ChannelBinding = {
12
+ event: string;
13
+ callback: EventCallback;
14
+ wrapped: EventCallback;
15
+ };
16
+
17
+ type PulseChannel = {
18
+ listen: <T = unknown>(event: string, callback: EventCallback<T>) => PulseChannel;
19
+ stopListening: (event: string, callback?: EventCallback) => PulseChannel;
20
+ };
21
+
22
+ export type PulseClient = {
23
+ connect: () => PulseClient;
24
+ disconnect: () => PulseClient;
25
+ channel: (name: string) => PulseChannel;
26
+ private: (room: string) => PulseChannel;
27
+ leave: (room: string) => PulseClient;
28
+ };
29
+
30
+ function createPulse(): PulseClient {
31
+ // 2. If disabled, return the no-op dummy client immediately
32
+ if (!isSocketEnabled) {
33
+ const dummyChannel = (): PulseChannel => ({
34
+ listen: () => dummyChannel(),
35
+ stopListening: () => dummyChannel()
36
+ });
37
+
38
+ const dummyClient: PulseClient = {
39
+ connect: () => dummyClient,
40
+ disconnect: () => dummyClient,
41
+ channel: dummyChannel,
42
+ private: dummyChannel,
43
+ leave: () => dummyClient
44
+ };
45
+
46
+ return dummyClient;
47
+ }
48
+
49
+ let socket: Socket | null = null;
50
+ const channels = new Map<string, ChannelBinding[]>();
51
+ const joinedRooms = new Set<string>();
52
+
53
+ const socketUrl = () => {
54
+ const explicit = import.meta.env.VITE_SOCKET_URL || import.meta.env.VITE_API_URL;
55
+ if (explicit) return explicit;
56
+ return window.location.origin;
57
+ };
58
+
59
+ const ensureConnected = () => {
60
+ if (socket) return socket;
61
+
62
+ socket = io(socketUrl(), {
63
+ withCredentials: true,
64
+ transports: ["websocket", "polling"]
65
+ });
66
+
67
+ socket.on("connect", () => {
68
+ for (const room of joinedRooms) {
69
+ socket?.emit("join", room);
70
+ }
71
+ });
72
+
73
+ return socket;
74
+ };
75
+
76
+ const channel = (name: string): PulseChannel => {
77
+ const room = String(name).trim();
78
+ if (!room) throw new Error("Channel name is required");
79
+
80
+ const currentSocket = ensureConnected();
81
+ joinedRooms.add(room);
82
+ currentSocket.emit("join", room);
83
+
84
+ const api: PulseChannel = {
85
+ listen: <T = unknown>(event: string, callback: EventCallback<T>) => {
86
+ const wrapped = (payload: unknown) => callback(payload as T);
87
+ const bindings = channels.get(room) || [];
88
+ bindings.push({ event, callback: callback as EventCallback, wrapped });
89
+ channels.set(room, bindings);
90
+ currentSocket.on(event, wrapped);
91
+ return api;
92
+ },
93
+ stopListening: (event: string, callback?: EventCallback) => {
94
+ const bindings = channels.get(room) || [];
95
+ const kept: ChannelBinding[] = [];
96
+
97
+ for (const binding of bindings) {
98
+ const matchesEvent = binding.event === event;
99
+ const matchesCallback = !callback || binding.callback === callback;
100
+ if (matchesEvent && matchesCallback) {
101
+ currentSocket.off(binding.event, binding.wrapped);
102
+ } else {
103
+ kept.push(binding);
104
+ }
105
+ }
106
+
107
+ if (kept.length > 0) channels.set(room, kept);
108
+ else channels.delete(room);
109
+ return api;
110
+ }
111
+ };
112
+
113
+ return api;
114
+ };
115
+
116
+ const pulse: PulseClient = {
117
+ connect: () => {
118
+ ensureConnected();
119
+ return pulse;
120
+ },
121
+ disconnect: () => {
122
+ socket?.disconnect();
123
+ socket = null;
124
+ return pulse;
125
+ },
126
+ channel,
127
+ private: (room: string) => channel(`private:${room}`),
128
+ leave: (room: string) => {
129
+ if (!socket) return pulse;
130
+
131
+ const bindings = channels.get(room) || [];
132
+ for (const binding of bindings) {
133
+ socket.off(binding.event, binding.wrapped);
134
+ }
135
+
136
+ channels.delete(room);
137
+ joinedRooms.delete(room);
138
+ return pulse;
139
+ }
140
+ };
141
+
142
+ return pulse;
143
+ }
144
+
145
+ export const pulse = createPulse();
146
+
147
+ export const PulsePlugin = {
148
+ install(app: App) {
149
+ app.config.globalProperties.$pulse = pulse;
150
+ }
151
+ };
152
+
153
+ declare module "vue" {
154
+ interface ComponentCustomProperties {
155
+ $pulse: PulseClient;
156
+ }
157
+ }