Skip to content

Commit 98b3a68

Browse files
authored
Merge pull request #1157 from writer/AB-591
feat(ui): close socket on inactivity - AB-591
2 parents 451b246 + 3cf4e08 commit 98b3a68

File tree

9 files changed

+166
-41
lines changed

9 files changed

+166
-41
lines changed

src/ui/src/builder/BuilderApp.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114

115115
<div id="modal"></div>
116116

117+
<BuilderAppSocketTimeoutModal />
117118
<!-- TOOLTIP -->
118119

119120
<BuilderTooltip id="tooltip" />
@@ -150,6 +151,8 @@ import BuilderCollaborationTracker from "./BuilderCollaborationTracker.vue";
150151
import BaseNote from "@/components/core/base/BaseNote.vue";
151152
import ShareResizeVertical from "@/components/shared/ShareResizeVertical.vue";
152153
import { defineAsyncComponentWithLoader } from "@/utils/defineAsyncComponentWithLoader";
154+
import BuilderAppSocketTimeoutModal from "./BuilderAppSocketTimeoutModal.vue";
155+
import { useSocketTimeout } from "./useSocketTimeout";
153156
154157
provide(injectionKeys.isAutogenModalShown, ref(false));
155158
@@ -178,6 +181,8 @@ const collaborationManager = inject(injectionKeys.collaborationManager);
178181
const tracking = useWriterTracking(wf);
179182
const toasts = useToasts();
180183
184+
provide(injectionKeys.socketTimeout, useSocketTimeout(wf, 10));
185+
181186
const noteEl = useTemplateRef("noteEl");
182187
183188
function refreshNotesPosition() {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import injectionKeys from "@/injectionKeys";
3+
import WdsModal from "@/wds/WdsModal.vue";
4+
import type { ModalAction } from "@/wds/WdsModal.vue";
5+
import { computed, inject } from "vue";
6+
7+
const socketTimeout = inject(injectionKeys.socketTimeout)!;
8+
9+
const actions = computed<ModalAction[]>(() => [
10+
{
11+
desc: "Reconnect",
12+
loading: socketTimeout.reconnecting.value,
13+
fn: () => {
14+
socketTimeout.reconnect();
15+
},
16+
},
17+
]);
18+
19+
const description = `You’ve been disconnected because you were inactive for ${socketTimeout.timeoutMin} minutes. Click below to reconnect and pick up where you left off.`;
20+
</script>
21+
22+
<template>
23+
<WdsModal
24+
v-if="socketTimeout.socketClosed.value"
25+
title="Session Expired"
26+
:description
27+
:actions
28+
>
29+
</WdsModal>
30+
</template>

src/ui/src/builder/panels/BuilderCodePanel.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,10 @@ const {
168168
save,
169169
} = useSourceFiles(wf);
170170
171-
const { enablePrevention, disablePrevention } = useUnsavedChangesPrevention();
171+
const socketTimeout = inject(injectionKeys.socketTimeout);
172+
173+
const { enablePrevention, disablePrevention } =
174+
useUnsavedChangesPrevention(socketTimeout);
172175
173176
const {
174177
showModal,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useAbortController } from "@/composables/useAbortController";
2+
import type { Core } from "@/writerTypes";
3+
import { ref, onMounted, watch } from "vue";
4+
5+
/**
6+
* @param timeoutMin the inactivity time required to close the socket
7+
*/
8+
export function useSocketTimeout(wf: Core, timeoutMin: number) {
9+
const timeoutMs = timeoutMin * 60 * 1_000;
10+
11+
let timer = undefined;
12+
13+
const socketClosed = ref(false);
14+
const reconnecting = ref(false);
15+
const prevent = ref(false);
16+
17+
function schedule() {
18+
if (document.visibilityState === "visible") return;
19+
clearSchedule();
20+
timer = setTimeout(() => {
21+
wf.stopSync();
22+
socketClosed.value = true;
23+
}, timeoutMs);
24+
}
25+
26+
function clearSchedule() {
27+
if (timer) clearTimeout(timer);
28+
}
29+
30+
watch(prevent, () => {
31+
if (prevent.value) clearSchedule();
32+
});
33+
34+
async function reconnect() {
35+
reconnecting.value = true;
36+
try {
37+
await wf.init();
38+
socketClosed.value = false;
39+
} catch {
40+
window.location.reload(); // fallback to full reload
41+
} finally {
42+
reconnecting.value = false;
43+
}
44+
}
45+
46+
const abort = useAbortController();
47+
48+
onMounted(() => {
49+
document.addEventListener(
50+
"visibilitychange",
51+
() => {
52+
if (document.visibilityState === "visible") {
53+
clearSchedule();
54+
} else if (!prevent.value) {
55+
schedule();
56+
}
57+
},
58+
{ signal: abort.signal },
59+
);
60+
});
61+
62+
return {
63+
timeoutMin,
64+
socketClosed,
65+
reconnecting,
66+
prevent,
67+
clearSchedule,
68+
schedule,
69+
reconnect,
70+
};
71+
}

src/ui/src/composables/useBlueprintRun.ts

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { generateCore } from "@/core";
2-
import { generateBuilderManager } from "@/builder/builderManager";
31
import { computed, readonly, Ref, ref, unref } from "vue";
42
import { useWriterTracking } from "./useWriterTracking";
3+
import type { BuilderManager, Core } from "@/writerTypes";
4+
import { inject } from "vue";
5+
import injectionKeys from "@/injectionKeys";
56

67
interface RunBlueprintResponse {
78
ok: boolean;
@@ -19,7 +20,7 @@ interface RunBlueprintResponse {
1920
}
2021

2122
function runBlueprint(
22-
wf: ReturnType<typeof generateCore>,
23+
wf: Core,
2324
blueprintComponentId: string,
2425
branchId?: string,
2526
) {
@@ -44,27 +45,21 @@ function runBlueprint(
4445
}
4546

4647
wf.forwardEvent(
47-
branchId ?
48-
new CustomEvent(
49-
"wf-run-blueprint-branch",
50-
{
51-
detail: {
52-
callback,
53-
handler: "run_blueprint_branch",
54-
payload: { "branch_id": branchId }
55-
},
56-
}
57-
) :
58-
new CustomEvent(
59-
"wf-run-blueprint",
60-
{
61-
detail: {
62-
callback,
63-
handler: "run_blueprint_by_id",
64-
payload: { blueprint_id: blueprintComponentId },
65-
},
66-
}
67-
),
48+
branchId
49+
? new CustomEvent("wf-run-blueprint-branch", {
50+
detail: {
51+
callback,
52+
handler: "run_blueprint_branch",
53+
payload: { branch_id: branchId },
54+
},
55+
})
56+
: new CustomEvent("wf-run-blueprint", {
57+
detail: {
58+
callback,
59+
handler: "run_blueprint_by_id",
60+
payload: { blueprint_id: blueprintComponentId },
61+
},
62+
}),
6863
null,
6964
true,
7065
).catch((err) => {
@@ -74,10 +69,7 @@ function runBlueprint(
7469
});
7570
}
7671

77-
function stopBlueprintRun(
78-
wf: ReturnType<typeof generateCore>,
79-
runId: string,
80-
) {
72+
function stopBlueprintRun(wf: Core, runId: string) {
8173
return new Promise<void>((res, rej) => {
8274
const tracking = useWriterTracking(wf);
8375
tracking.track("blueprints_run_stopped");
@@ -86,23 +78,25 @@ function stopBlueprintRun(
8678
new CustomEvent("wf-stop-blueprint", {
8779
detail: {
8880
handler: "stop_blueprint_run",
89-
payload: { run_id: runId},
81+
payload: { run_id: runId },
9082
},
9183
}),
9284
null,
9385
true,
9486
)
9587
.then(() => res())
9688
.catch((err) => {
97-
tracking.track("blueprints_run_stop_failed", { error: String(err) });
89+
tracking.track("blueprints_run_stop_failed", {
90+
error: String(err),
91+
});
9892
rej(err);
9993
});
10094
});
10195
}
10296

10397
export function useBlueprintRun(
104-
wf: ReturnType<typeof generateCore>,
105-
wfbm: ReturnType<typeof generateBuilderManager>,
98+
wf: Core,
99+
wfbm: BuilderManager,
106100
blueprintComponentId: string | Ref<string>,
107101
) {
108102
const isRunning = ref(false);
@@ -119,7 +113,7 @@ export function useBlueprintRun(
119113

120114
async function stop() {
121115
const activeRunId = wfbm.activeBlueprintRunId.value;
122-
if(!activeRunId) return;
116+
if (!activeRunId) return;
123117
await stopBlueprintRun(wf, activeRunId);
124118
}
125119

@@ -130,10 +124,11 @@ export type BlueprintsRunListItem = { blueprintId: string; branchId: string };
130124
type MaybeRef<T> = T | Ref<T>;
131125

132126
export function useBlueprintsRun(
133-
wf: ReturnType<typeof generateCore>,
127+
wf: Core,
134128
blueprintComponentIds: MaybeRef<BlueprintsRunListItem[]>,
135129
) {
136130
const runningBlueprintIds = ref<string[]>([]);
131+
const socketTimeout = inject(injectionKeys.socketTimeout);
137132

138133
async function handleRunBlueprint({
139134
blueprintId,
@@ -142,6 +137,7 @@ export function useBlueprintsRun(
142137
if (runningBlueprintIds.value.includes(blueprintId)) return;
143138

144139
try {
140+
if (socketTimeout) socketTimeout.prevent.value = true;
145141
runningBlueprintIds.value = [
146142
blueprintId,
147143
...runningBlueprintIds.value,
@@ -151,6 +147,7 @@ export function useBlueprintsRun(
151147
runningBlueprintIds.value = runningBlueprintIds.value.filter(
152148
(id) => id !== blueprintId,
153149
);
150+
if (socketTimeout) socketTimeout.prevent.value = false;
154151
}
155152
}
156153
async function run() {

src/ui/src/composables/useUnsavedChangesPrevention.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import { ref, onMounted, onUnmounted } from "vue";
22
import { useAbortController } from "@/composables/useAbortController";
3+
import { SocketTimeout } from "@/writerTypes";
34

45
const BEFORE_UNLOAD_MESSAGE =
56
"You have unsaved changes. Are you sure you want to leave?";
67

7-
export function useUnsavedChangesPrevention() {
8+
export function useUnsavedChangesPrevention(socketTimeout?: SocketTimeout) {
89
const hasUnsavedChanges = ref(false);
910
const beforeUnloadHandler = ref<
1011
((event: BeforeUnloadEvent) => void) | null
1112
>(null);
1213

1314
function enablePrevention() {
1415
hasUnsavedChanges.value = true;
16+
if (socketTimeout) socketTimeout.prevent.value = true;
1517
}
1618

1719
function disablePrevention() {
1820
hasUnsavedChanges.value = false;
21+
if (socketTimeout) socketTimeout.prevent.value = false;
1922
}
2023

2124
function handleBeforeUnload(event: BeforeUnloadEvent) {

src/ui/src/core/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function generateCore() {
8282
let mailInbox: MailItem[] = [];
8383
let mailSubscriptions: { mailType: string; fn: Function }[] = [];
8484
const collaborationPingSubscriptions: { fn: Function }[] = [];
85-
85+
8686
let pendingComponentUpdate = false;
8787

8888
const activePageId = ref<Component["id"] | undefined>();
@@ -241,7 +241,7 @@ export function generateCore() {
241241
// Open and setup websocket
242242

243243
async function startSync(): Promise<void> {
244-
if (webSocket) return; // Open WebSocket exists
244+
if (webSocket && syncHealth.value === "connected") return; // Open WebSocket exists
245245

246246
const logger = useLogger();
247247

@@ -254,11 +254,14 @@ export function generateCore() {
254254
syncHealth.value = "connected";
255255
logger.log("WebSocket connected. Initialising stream...");
256256
sendFrontendMessage("streamInit", { sessionId });
257-
257+
258258
if (pendingComponentUpdate) {
259259
pendingComponentUpdate = false;
260260
sendComponentUpdate().catch((error) => {
261-
logger.error("Failed to retry component update after reconnect:", error);
261+
logger.error(
262+
"Failed to retry component update after reconnect:",
263+
error,
264+
);
262265
pendingComponentUpdate = true;
263266
});
264267
}
@@ -401,6 +404,13 @@ export function generateCore() {
401404
});
402405
}
403406

407+
function stopSync(): void {
408+
if (!webSocket) return;
409+
webSocket.onclose = () => {};
410+
webSocket.close();
411+
syncHealth.value = "offline";
412+
}
413+
404414
/**
405415
* Dispatches the given mail to the relevant mail subscriptions.
406416
* Items that cannot be distributed remain in the inbox.
@@ -951,6 +961,7 @@ export function generateCore() {
951961
isChildOf,
952962
featureFlags: readonly(featureFlags),
953963
getWebSocket,
964+
stopSync,
954965
// writer cloud variables
955966
writerApplication: readonly(writerApplication),
956967
isWriterCloudApp,

src/ui/src/injectionKeys.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ComputedRef, InjectionKey, Ref, VNode } from "vue";
2-
import {
2+
import type {
33
BuilderManager,
44
CollaborationManager,
55
Component,
@@ -8,6 +8,7 @@ import {
88
InstancePathItem,
99
NotesManager,
1010
SecretsManager,
11+
SocketTimeout,
1112
} from "./writerTypes";
1213

1314
export default {
@@ -34,4 +35,5 @@ export default {
3435
flattenedInstancePath: Symbol() as InjectionKey<string>,
3536
instanceData: Symbol() as InjectionKey<Ref[]>,
3637
isAutogenModalShown: Symbol() as InjectionKey<Ref<boolean>>,
38+
socketTimeout: Symbol() as InjectionKey<SocketTimeout>,
3739
};

0 commit comments

Comments
 (0)