@antelopejs/dms 0.5.1 → 0.5.2
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/CHANGELOG.md +33 -0
- package/dist/implementations/dms/page.js +69 -22
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/sessions.d.ts +3 -0
- package/dist/realtime/sessions.js +5 -0
- package/dist/realtime/sessions.js.map +1 -1
- package/dist/realtime/sse.d.ts +1 -1
- package/dist/realtime/sse.js +9 -3
- package/dist/realtime/sse.js.map +1 -1
- package/dist/routes/realtime.js +8 -3
- package/dist/routes/realtime.js.map +1 -1
- package/dist/test/helpers/reload-host.d.ts +7 -2
- package/dist/test/helpers/reload-host.js +23 -4
- package/dist/test/helpers/reload-host.js.map +1 -1
- package/dist/test/reload-host/index.js +27 -1
- package/dist/test/reload-host/index.js.map +1 -1
- package/dist/test/unit/implementations/dms/missing-category.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js +174 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js +92 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js +59 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js +2 -2
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js +153 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js.map +1 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.d.ts +1 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js +99 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js.map +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.d.ts +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.js +137 -0
- package/dist/test/unit/skills/dms-dev-script.test.js.map +1 -0
- package/dist/validation/auth.schema.d.ts +2 -2
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +116 -44
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +2 -0
- package/frontend-vue/layers/dms-ui/app/plugins/shortcuts.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/types/shortcuts-aggregated.d.ts +8 -0
- package/frontend-vue/tests/realtime-reconnect.test.ts +215 -0
- package/frontend-vue/tests/shortcuts-aggregate.test.ts +41 -0
- package/frontend-vue/vitest.config.ts +4 -0
- package/package.json +2 -2
- package/skills/dms-dev/SKILL.md +28 -16
- package/skills/dms-dev/scripts/dms-dev.sh +275 -83
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isUnauthorizedStreamError,
|
|
3
|
+
openSseStream,
|
|
4
|
+
type SseStreamHandle,
|
|
5
|
+
} from "./useSseStream";
|
|
2
6
|
|
|
3
7
|
const REALTIME_USER_PATH = "/api/realtime/user";
|
|
4
8
|
const REALTIME_SUBSCRIBE_PATH_PREFIX = "/api/realtime/subscribe/";
|
|
@@ -117,57 +121,109 @@ const handleEvent =
|
|
|
117
121
|
}
|
|
118
122
|
};
|
|
119
123
|
|
|
124
|
+
interface ConnectionCallbacks {
|
|
125
|
+
onConnected: () => void;
|
|
126
|
+
onDisconnected: () => void;
|
|
127
|
+
refreshSession: () => Promise<boolean>;
|
|
128
|
+
redirectToAuth: () => Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface Reconnector {
|
|
132
|
+
isCancelled(): boolean;
|
|
133
|
+
schedule(open: () => void): void;
|
|
134
|
+
resetDelay(): void;
|
|
135
|
+
cancel(): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const createReconnector = (): Reconnector => {
|
|
139
|
+
let isCancelled = false;
|
|
140
|
+
let delay = RECONNECT_INITIAL_DELAY_MS;
|
|
141
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
return {
|
|
143
|
+
isCancelled: () => isCancelled,
|
|
144
|
+
schedule: (open) => {
|
|
145
|
+
// A pending reconnect absorbs concurrent triggers: one stream per tab.
|
|
146
|
+
if (isCancelled || timer) return;
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
timer = undefined;
|
|
149
|
+
open();
|
|
150
|
+
}, delay);
|
|
151
|
+
delay = Math.min(delay * RECONNECT_MULTIPLIER, RECONNECT_MAX_DELAY_MS);
|
|
152
|
+
},
|
|
153
|
+
resetDelay: () => {
|
|
154
|
+
delay = RECONNECT_INITIAL_DELAY_MS;
|
|
155
|
+
},
|
|
156
|
+
cancel: () => {
|
|
157
|
+
isCancelled = true;
|
|
158
|
+
if (timer) clearTimeout(timer);
|
|
159
|
+
timer = undefined;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const recoverFromError = (
|
|
165
|
+
error: unknown,
|
|
166
|
+
reconnect: () => void,
|
|
167
|
+
callbacks: ConnectionCallbacks,
|
|
168
|
+
): void => {
|
|
169
|
+
if (!isUnauthorizedStreamError(error)) {
|
|
170
|
+
reconnect();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void callbacks.refreshSession().then((isRefreshed) => {
|
|
174
|
+
if (isRefreshed) {
|
|
175
|
+
reconnect();
|
|
176
|
+
} else {
|
|
177
|
+
void callbacks.redirectToAuth();
|
|
178
|
+
}
|
|
179
|
+
}, reconnect);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keeps one user stream open until cancelled. The server ends the stream when
|
|
184
|
+
* it stops (a dev reload included), so a clean EOF reconnects just like an
|
|
185
|
+
* error does: the user stream has no business completion.
|
|
186
|
+
*/
|
|
120
187
|
const connectWithBackoff = (
|
|
121
188
|
url: string,
|
|
122
189
|
state: UserRealtimeState,
|
|
123
|
-
|
|
124
|
-
refreshSession: () => Promise<boolean>,
|
|
125
|
-
redirectToAuth: () => Promise<void>,
|
|
190
|
+
callbacks: ConnectionCallbacks,
|
|
126
191
|
): (() => void) => {
|
|
127
|
-
|
|
128
|
-
let delay = RECONNECT_INITIAL_DELAY_MS;
|
|
129
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
-
let activeHandle: { close(): void } | undefined;
|
|
192
|
+
const reconnector = createReconnector();
|
|
131
193
|
const dispatch = handleEvent(state);
|
|
194
|
+
let activeHandle: SseStreamHandle | undefined;
|
|
132
195
|
const open = () => {
|
|
133
|
-
if (
|
|
134
|
-
activeHandle
|
|
196
|
+
if (reconnector.isCancelled()) return;
|
|
197
|
+
activeHandle?.close();
|
|
198
|
+
const handle: SseStreamHandle = openSseStream({
|
|
135
199
|
url,
|
|
136
200
|
onEvent: (eventName, data) => {
|
|
137
|
-
if (
|
|
138
|
-
|
|
201
|
+
if (handle !== activeHandle) return;
|
|
202
|
+
reconnector.resetDelay();
|
|
139
203
|
dispatch(eventName, data);
|
|
140
|
-
if (eventName === HELLO_EVENT) onConnected();
|
|
204
|
+
if (eventName === HELLO_EVENT) callbacks.onConnected();
|
|
141
205
|
},
|
|
142
206
|
onError: (error) => {
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
delay * RECONNECT_MULTIPLIER,
|
|
150
|
-
RECONNECT_MAX_DELAY_MS,
|
|
151
|
-
);
|
|
152
|
-
};
|
|
153
|
-
if (isUnauthorizedStreamError(error)) {
|
|
154
|
-
void refreshSession().then((refreshed) => {
|
|
155
|
-
if (refreshed) {
|
|
156
|
-
scheduleReconnect();
|
|
157
|
-
} else {
|
|
158
|
-
void redirectToAuth();
|
|
159
|
-
}
|
|
160
|
-
}, scheduleReconnect);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
scheduleReconnect();
|
|
207
|
+
if (!retire(handle)) return;
|
|
208
|
+
recoverFromError(error, reconnect, callbacks);
|
|
209
|
+
},
|
|
210
|
+
onClose: (reason) => {
|
|
211
|
+
if (reason !== "eof" || !retire(handle)) return;
|
|
212
|
+
reconnect();
|
|
164
213
|
},
|
|
165
214
|
});
|
|
215
|
+
activeHandle = handle;
|
|
216
|
+
};
|
|
217
|
+
const reconnect = () => reconnector.schedule(open);
|
|
218
|
+
const retire = (handle: SseStreamHandle): boolean => {
|
|
219
|
+
if (handle !== activeHandle || reconnector.isCancelled()) return false;
|
|
220
|
+
activeHandle = undefined;
|
|
221
|
+
callbacks.onDisconnected();
|
|
222
|
+
return true;
|
|
166
223
|
};
|
|
167
224
|
open();
|
|
168
225
|
return () => {
|
|
169
|
-
|
|
170
|
-
if (timer) clearTimeout(timer);
|
|
226
|
+
reconnector.cancel();
|
|
171
227
|
activeHandle?.close();
|
|
172
228
|
activeHandle = undefined;
|
|
173
229
|
};
|
|
@@ -196,14 +252,29 @@ const reconcilePage = async (
|
|
|
196
252
|
baseUrl: string,
|
|
197
253
|
): Promise<void> => {
|
|
198
254
|
const desired = state.pageId.value ?? null;
|
|
255
|
+
const sessionId = state.sessionId.value;
|
|
199
256
|
if (state.appliedPageId.value === desired) return;
|
|
200
|
-
if (!
|
|
257
|
+
if (!sessionId) return;
|
|
201
258
|
await callPageEndpoint(state, desired, baseUrl);
|
|
259
|
+
// A reconnect during the call replaced the session: the page is applied to
|
|
260
|
+
// the dead one, and the new one still needs it.
|
|
261
|
+
if (state.sessionId.value !== sessionId) return;
|
|
202
262
|
if ((state.pageId.value ?? null) === desired) {
|
|
203
263
|
state.appliedPageId.value = desired;
|
|
204
264
|
}
|
|
205
265
|
};
|
|
206
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Forgets what the server held for the closed session. The next greeting
|
|
269
|
+
* brings a new session id, which re-applies the page topics here and lets the
|
|
270
|
+
* components holding presence re-acquire it.
|
|
271
|
+
*/
|
|
272
|
+
const resetSession = (state: UserRealtimeState): void => {
|
|
273
|
+
state.sessionId.value = undefined;
|
|
274
|
+
state.appliedPageId.value = null;
|
|
275
|
+
state.snapshots.clear();
|
|
276
|
+
};
|
|
277
|
+
|
|
207
278
|
const buildApi = (): UserRealtimeApi => {
|
|
208
279
|
const sessionId = useDmsState<string | undefined>(
|
|
209
280
|
SESSION_STATE_KEY,
|
|
@@ -232,19 +303,20 @@ const buildApi = (): UserRealtimeApi => {
|
|
|
232
303
|
(isLoggedIn) => {
|
|
233
304
|
state.cancelConnect?.();
|
|
234
305
|
state.cancelConnect = undefined;
|
|
235
|
-
state
|
|
306
|
+
resetSession(state);
|
|
236
307
|
state.menuTopics.value = [];
|
|
237
|
-
state.appliedPageId.value = null;
|
|
238
|
-
state.snapshots.clear();
|
|
239
308
|
if (!isLoggedIn) return;
|
|
240
309
|
state.cancelConnect = connectWithBackoff(
|
|
241
310
|
`${baseUrl}${REALTIME_USER_PATH}`,
|
|
242
311
|
state,
|
|
243
|
-
|
|
244
|
-
|
|
312
|
+
{
|
|
313
|
+
onConnected: () => {
|
|
314
|
+
void reconcilePage(state, baseUrl);
|
|
315
|
+
},
|
|
316
|
+
onDisconnected: () => resetSession(state),
|
|
317
|
+
refreshSession,
|
|
318
|
+
redirectToAuth,
|
|
245
319
|
},
|
|
246
|
-
refreshSession,
|
|
247
|
-
redirectToAuth,
|
|
248
320
|
);
|
|
249
321
|
},
|
|
250
322
|
{ immediate: true },
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { withoutTrailingSlash } from "ufo";
|
|
3
3
|
|
|
4
|
+
defineOptions({ inheritAttrs: false });
|
|
5
|
+
|
|
4
6
|
// Dev-only: created behind the flag so the key never reaches a production
|
|
5
7
|
// SSR payload.
|
|
6
8
|
const devReloading = import.meta.env.DEV ? useDevReloading() : ref(false);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Generated by the renderer in the workspace: every module's
|
|
2
|
+
// `app/config/shortcuts-registry.ts`, spread in priority order.
|
|
3
|
+
declare module "#shortcuts-aggregated" {
|
|
4
|
+
import type { ComponentShortcuts } from "#dms-ui/app/types/shortcuts";
|
|
5
|
+
|
|
6
|
+
const aggregatedShortcuts: ComponentShortcuts[];
|
|
7
|
+
export default aggregatedShortcuts;
|
|
8
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { nextTick, ref, watch } from "vue";
|
|
3
|
+
import type { UserRealtimeApi } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
|
|
4
|
+
|
|
5
|
+
const BASE_URL = "http://api";
|
|
6
|
+
const USER_STREAM_URL = `${BASE_URL}/api/realtime/user`;
|
|
7
|
+
const SUBSCRIBE_URL_PREFIX = `${BASE_URL}/api/realtime/subscribe/`;
|
|
8
|
+
const SESSION_HEADER = "x-realtime-session";
|
|
9
|
+
const PAGE_ID = "orders";
|
|
10
|
+
const INITIAL_DELAY_MS = 500;
|
|
11
|
+
const SECOND_DELAY_MS = 1000;
|
|
12
|
+
const LONG_WAIT_MS = 120_000;
|
|
13
|
+
|
|
14
|
+
interface FakeStream {
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
end(): void;
|
|
17
|
+
isOpen(): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PageSubscription {
|
|
21
|
+
pageId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FakeServer {
|
|
26
|
+
streams: FakeStream[];
|
|
27
|
+
subscriptions: PageSubscription[];
|
|
28
|
+
maxOpenStreams: number;
|
|
29
|
+
isDown: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
|
|
34
|
+
const helloFrame = (sessionId: string): Uint8Array =>
|
|
35
|
+
encoder.encode(`event: hello\ndata: ${JSON.stringify({ sessionId })}\n\n`);
|
|
36
|
+
|
|
37
|
+
const openStreamCount = (server: FakeServer): number =>
|
|
38
|
+
server.streams.filter((stream) => stream.isOpen()).length;
|
|
39
|
+
|
|
40
|
+
const openUserStream = (server: FakeServer, signal: AbortSignal) => {
|
|
41
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
42
|
+
let isOpen = true;
|
|
43
|
+
const body = new ReadableStream<Uint8Array>({
|
|
44
|
+
start(streamController) {
|
|
45
|
+
controller = streamController;
|
|
46
|
+
},
|
|
47
|
+
cancel() {
|
|
48
|
+
isOpen = false;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
signal.addEventListener("abort", () => {
|
|
52
|
+
isOpen = false;
|
|
53
|
+
});
|
|
54
|
+
server.streams.push({
|
|
55
|
+
signal,
|
|
56
|
+
end: () => {
|
|
57
|
+
isOpen = false;
|
|
58
|
+
controller.close();
|
|
59
|
+
},
|
|
60
|
+
isOpen: () => isOpen && !signal.aborted,
|
|
61
|
+
});
|
|
62
|
+
server.maxOpenStreams = Math.max(
|
|
63
|
+
server.maxOpenStreams,
|
|
64
|
+
openStreamCount(server),
|
|
65
|
+
);
|
|
66
|
+
controller.enqueue(helloFrame(`session-${server.streams.length}`));
|
|
67
|
+
return { ok: true, status: 200, body };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const installFetch = (server: FakeServer): void => {
|
|
71
|
+
globalThis.fetch = vi.fn(async (url: string, init?: RequestInit) => {
|
|
72
|
+
if (url === USER_STREAM_URL) {
|
|
73
|
+
if (server.isDown) throw new Error("connection refused");
|
|
74
|
+
return openUserStream(server, init!.signal!);
|
|
75
|
+
}
|
|
76
|
+
const headers = init?.headers as Record<string, string>;
|
|
77
|
+
server.subscriptions.push({
|
|
78
|
+
pageId: url.slice(SUBSCRIBE_URL_PREFIX.length),
|
|
79
|
+
sessionId: headers[SESSION_HEADER]!,
|
|
80
|
+
});
|
|
81
|
+
return { ok: true, status: 200 };
|
|
82
|
+
}) as unknown as typeof fetch;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const settle = async (): Promise<void> => {
|
|
86
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
87
|
+
await nextTick();
|
|
88
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const realFetch = globalThis.fetch;
|
|
92
|
+
let loggedIn = ref(false);
|
|
93
|
+
let server: FakeServer;
|
|
94
|
+
|
|
95
|
+
const loadApi = async (): Promise<UserRealtimeApi> => {
|
|
96
|
+
const module = await import(
|
|
97
|
+
"../layers/dms-core/app/composables/realtime/useUserRealtime"
|
|
98
|
+
);
|
|
99
|
+
return module.useUserRealtime();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const lastStream = (): FakeStream => server.streams[server.streams.length - 1]!;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.resetModules();
|
|
106
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
107
|
+
vi.stubEnv("SSR", false);
|
|
108
|
+
vi.stubGlobal("ref", ref);
|
|
109
|
+
vi.stubGlobal("watch", watch);
|
|
110
|
+
vi.stubGlobal("useDmsState", (_key: string, init: () => unknown) =>
|
|
111
|
+
ref(init()),
|
|
112
|
+
);
|
|
113
|
+
vi.stubGlobal("useDmsRuntimeConfig", () => ({
|
|
114
|
+
public: { dms: { baseURL: BASE_URL } },
|
|
115
|
+
}));
|
|
116
|
+
vi.stubGlobal("useUserSession", () => ({ loggedIn }));
|
|
117
|
+
vi.stubGlobal("useSessionRecovery", () => ({
|
|
118
|
+
refreshSession: async () => true,
|
|
119
|
+
redirectToAuth: async () => {},
|
|
120
|
+
}));
|
|
121
|
+
server = { streams: [], subscriptions: [], maxOpenStreams: 0, isDown: false };
|
|
122
|
+
installFetch(server);
|
|
123
|
+
// Fresh per test: the singleton of a previous test keeps watching its own.
|
|
124
|
+
loggedIn = ref(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
loggedIn.value = false;
|
|
129
|
+
await settle();
|
|
130
|
+
globalThis.fetch = realFetch;
|
|
131
|
+
vi.useRealTimers();
|
|
132
|
+
vi.unstubAllGlobals();
|
|
133
|
+
vi.unstubAllEnvs();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("useUserRealtime reconnection", () => {
|
|
137
|
+
it("reconnects with backoff when the server ends the stream, then resubscribes the page", async () => {
|
|
138
|
+
const api = await loadApi();
|
|
139
|
+
await settle();
|
|
140
|
+
await api.setPage(PAGE_ID);
|
|
141
|
+
expect(api.sessionId.value).toBe("session-1");
|
|
142
|
+
expect(server.subscriptions).toEqual([
|
|
143
|
+
{ pageId: PAGE_ID, sessionId: "session-1" },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
lastStream().end();
|
|
147
|
+
await settle();
|
|
148
|
+
expect(api.sessionId.value).toBeUndefined();
|
|
149
|
+
await vi.advanceTimersByTimeAsync(INITIAL_DELAY_MS - 1);
|
|
150
|
+
expect(server.streams).toHaveLength(1);
|
|
151
|
+
|
|
152
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
153
|
+
await settle();
|
|
154
|
+
expect(server.streams).toHaveLength(2);
|
|
155
|
+
expect(api.sessionId.value).toBe("session-2");
|
|
156
|
+
expect(server.subscriptions).toEqual([
|
|
157
|
+
{ pageId: PAGE_ID, sessionId: "session-1" },
|
|
158
|
+
{ pageId: PAGE_ID, sessionId: "session-2" },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("backs off further while the server stays unreachable", async () => {
|
|
163
|
+
await loadApi();
|
|
164
|
+
await settle();
|
|
165
|
+
server.isDown = true;
|
|
166
|
+
|
|
167
|
+
lastStream().end();
|
|
168
|
+
await settle();
|
|
169
|
+
await vi.advanceTimersByTimeAsync(INITIAL_DELAY_MS);
|
|
170
|
+
await settle();
|
|
171
|
+
expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(2);
|
|
172
|
+
|
|
173
|
+
server.isDown = false;
|
|
174
|
+
await vi.advanceTimersByTimeAsync(SECOND_DELAY_MS - 1);
|
|
175
|
+
expect(server.streams).toHaveLength(1);
|
|
176
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
177
|
+
await settle();
|
|
178
|
+
expect(server.streams).toHaveLength(2);
|
|
179
|
+
expect(lastStream().isOpen()).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not reconnect after an intentional close", async () => {
|
|
183
|
+
await loadApi();
|
|
184
|
+
await settle();
|
|
185
|
+
const stream = lastStream();
|
|
186
|
+
|
|
187
|
+
loggedIn.value = false;
|
|
188
|
+
await settle();
|
|
189
|
+
expect(stream.signal.aborted).toBe(true);
|
|
190
|
+
|
|
191
|
+
await vi.advanceTimersByTimeAsync(LONG_WAIT_MS);
|
|
192
|
+
await settle();
|
|
193
|
+
expect(server.streams).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never holds more than one stream at a time", async () => {
|
|
197
|
+
await loadApi();
|
|
198
|
+
await settle();
|
|
199
|
+
|
|
200
|
+
for (let cycle = 0; cycle < 3; cycle += 1) {
|
|
201
|
+
lastStream().end();
|
|
202
|
+
await settle();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(INITIAL_DELAY_MS);
|
|
204
|
+
await settle();
|
|
205
|
+
}
|
|
206
|
+
loggedIn.value = false;
|
|
207
|
+
await settle();
|
|
208
|
+
loggedIn.value = true;
|
|
209
|
+
await settle();
|
|
210
|
+
|
|
211
|
+
expect(server.streams).toHaveLength(5);
|
|
212
|
+
expect(server.maxOpenStreams).toBe(1);
|
|
213
|
+
expect(openStreamCount(server)).toBe(1);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const finderShortcut = {
|
|
4
|
+
id: "finder.open",
|
|
5
|
+
keys: ["enter"],
|
|
6
|
+
description: "Open",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
vi.mock("#shortcuts-aggregated", () => ({
|
|
10
|
+
default: [
|
|
11
|
+
{
|
|
12
|
+
component: "Tab",
|
|
13
|
+
shortcuts: [{ id: "tab.next", keys: ["ctrl", "tab"], description: "" }],
|
|
14
|
+
},
|
|
15
|
+
{ component: "Finder", shortcuts: [finderShortcut] },
|
|
16
|
+
],
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe("the shortcuts plugin", () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.unstubAllGlobals();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("registers every module's registry from the aggregate, not only dms-ui's", async () => {
|
|
25
|
+
const registered: Array<[string, unknown]> = [];
|
|
26
|
+
vi.stubGlobal("defineDmsPlugin", (setup: () => void) => setup);
|
|
27
|
+
vi.stubGlobal("useShortcutRegistry", () => ({
|
|
28
|
+
registerShortcut: (component: string, shortcut: unknown) =>
|
|
29
|
+
registered.push([component, shortcut]),
|
|
30
|
+
}));
|
|
31
|
+
const { default: setup } = await import(
|
|
32
|
+
"../layers/dms-ui/app/plugins/shortcuts"
|
|
33
|
+
);
|
|
34
|
+
(setup as () => void)();
|
|
35
|
+
expect(registered.map(([component]) => component)).toEqual([
|
|
36
|
+
"Tab",
|
|
37
|
+
"Finder",
|
|
38
|
+
]);
|
|
39
|
+
expect(registered[1]?.[1]).toEqual(finderShortcut);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -9,6 +9,10 @@ export default defineConfig({
|
|
|
9
9
|
"#dms-core": resolve(__dirname, "layers/dms-core"),
|
|
10
10
|
"#dms-ui": resolve(__dirname, "layers/dms-ui"),
|
|
11
11
|
"#dms-layout": resolve(__dirname, "layers/dms-layout"),
|
|
12
|
+
"#shortcuts-aggregated": resolve(
|
|
13
|
+
__dirname,
|
|
14
|
+
"layers/dms-ui/app/config/shortcuts-registry.ts",
|
|
15
|
+
),
|
|
12
16
|
},
|
|
13
17
|
},
|
|
14
18
|
esbuild: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/AntelopeJS/dms.git"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@antelopejs/interface-data-api": ">=0.1.9 <1.0.0",
|
|
37
37
|
"@antelopejs/interface-database": ">=0.1.8 <1.0.0",
|
|
38
38
|
"@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
|
|
39
|
-
"@antelopejs/interface-dms": ">=0.3.
|
|
39
|
+
"@antelopejs/interface-dms": ">=0.3.3 <0.4.0",
|
|
40
40
|
"@antelopejs/interface-email": ">=0.0.5 <1.0.0",
|
|
41
41
|
"@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
|
|
42
42
|
"archiver": "^7.0.1",
|
package/skills/dms-dev/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: dms-dev
|
|
|
3
3
|
description: Starts, stops, or restarts the AntelopeJS DMS backend and Inertia frontend.
|
|
4
4
|
category: tooling
|
|
5
5
|
tags: [dev-server, restart, backend, inertia, workflow]
|
|
6
|
-
allowed-tools: Bash(bash:*), Bash(
|
|
6
|
+
allowed-tools: Bash(bash:*), Bash(pgrep:*), Bash(tail:*), Bash(curl:*), Read, Write
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# dms-dev
|
|
@@ -14,6 +14,15 @@ Owns the dev-server lifecycle for an AntelopeJS DMS project. Ships with the
|
|
|
14
14
|
resolve the project path. For authoring see the sibling **dms**, **dms-module**,
|
|
15
15
|
**dms-pages**, and **dms-auth** skills.
|
|
16
16
|
|
|
17
|
+
## Never from inside the DMS
|
|
18
|
+
|
|
19
|
+
An agent that runs inside the DMS -- the dms-ai assistant, whose sidecar the backend
|
|
20
|
+
spawns, or any process started by these dev servers -- must **not** run `stop`, `restart`
|
|
21
|
+
or `start`: stopping the backend takes the agent down with it. Rely on hot reload (the
|
|
22
|
+
backend's `-w` watcher and the frontend's live layer resync) and, when a change needs a
|
|
23
|
+
full restart, ask the user to run it. The script refuses to stop a process tree that
|
|
24
|
+
contains the shell calling it.
|
|
25
|
+
|
|
17
26
|
## Choose the process manager
|
|
18
27
|
|
|
19
28
|
In an Amp orb, use the repository's `.amp/services.yaml` with `amp orb services ensure`,
|
|
@@ -21,18 +30,18 @@ or `amp orb service start` for a one-off service. Inspect readiness with service
|
|
|
21
30
|
HTTP checks; share the returned portal URL for browser review. The bundled detached-shell
|
|
22
31
|
script is for local shells, not managed orbs: its processes do not survive orb updates.
|
|
23
32
|
|
|
24
|
-
##
|
|
33
|
+
## Hot reload first, restart when it cannot apply
|
|
25
34
|
|
|
26
35
|
The DMS has two coupled processes, both started from the DMS backend package directory:
|
|
27
36
|
|
|
28
|
-
- **Backend**: the AntelopeJS project — `ajs project dev -w` (`ajs project run` is the legacy alias).
|
|
29
|
-
- **Frontend**: the `@antelopejs/dms-frontend` loader — `ajs dms dev`. It auto-discovers the running backend via `.antelope/dev.json`, so `-b <backend-url>` is optional.
|
|
37
|
+
- **Backend**: the AntelopeJS project — `ajs project dev -w` (`ajs project run` is the legacy alias). Ready when the log emits `Server started, listening on <url>`; the real endpoint is recorded in the project's `.antelope/dev.json` (`servers.api.endpoints`), `http://localhost:5010` by default.
|
|
38
|
+
- **Frontend**: the `@antelopejs/dms-frontend` loader — `ajs dms dev`. It auto-discovers the running backend via `.antelope/dev.json`, so `-b <backend-url>` is optional. Ready when the log emits `Server ready on <url>` (`http://localhost:3001` by default).
|
|
30
39
|
|
|
31
40
|
By default the script runs each via the project's `pnpm dev` / `pnpm frontend:dev` scripts — a convention our playground projects adopt that expands to the two commands above. Those script names aren't shipped by the DMS, so if a project starts its servers differently, override the actual commands with the `DMS_BACK_CMD` / `DMS_FRONT_CMD` env vars (the script reads them; readiness detection and process cleanup already cover `ajs`, `ajs dms`, and the loader's Node entry point directly).
|
|
32
41
|
|
|
33
42
|
Order matters: start the backend first and wait for ready, **then** start the frontend. The frontend materializes the backend's layers into a `~/.antelopejs/dms-frontend/` workspace at startup; starting it before the backend is up produces stale or empty layer copies.
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
Dev-mode watchers propagate most edits (backend `-w` recompile, live layer resync into `~/.antelopejs/dms-frontend/`). Their failure modes are silent, though: `RegisterModule` can throw "already registered" on rebuild, and a missed resync serves a stale layer copy that *looks* like your bug. So restart only when hot reload cannot apply the change -- a new module or dependency, a config edit, a backend log showing a failed rebuild, or a behaviour that still looks stale after the watcher reported the rebuild -- and then restart both, backend first. Never restart from inside the DMS (see above).
|
|
36
45
|
|
|
37
46
|
## Resolving the backend path (do this FIRST, eagerly)
|
|
38
47
|
|
|
@@ -71,27 +80,29 @@ bundled script lives at `scripts/dms-dev.sh` under it. Subcommands:
|
|
|
71
80
|
|
|
72
81
|
| Command | Use when |
|
|
73
82
|
|-------------------|-------------------------------------------------------------------------|
|
|
74
|
-
| `restart` |
|
|
83
|
+
| `restart` | When hot reload cannot apply a change, or the state is unknown. Never from inside the DMS. |
|
|
75
84
|
| `start` | When you're sure nothing is running (fresh shell / after `stop`). |
|
|
76
|
-
| `stop` | Before walking away, before destructive operations, on session cleanup. |
|
|
85
|
+
| `stop` | Before walking away, before destructive operations, on session cleanup. Never from inside the DMS. |
|
|
77
86
|
| `status` | Quickly check what's running. |
|
|
78
87
|
| `back-log [N]` | Read the last N (default 60) backend log lines. Use to diagnose boot. |
|
|
79
88
|
| `front-log [N]` | Same for the frontend log. |
|
|
80
89
|
|
|
81
|
-
The script writes the long-running server logs to `/tmp/dms-back.log` and `/tmp/dms-front.log`. The `Bash` tool invocation completes as soon as both servers print their ready strings
|
|
90
|
+
The script writes the long-running server logs to `/tmp/dms-back.log` and `/tmp/dms-front.log`. The `Bash` tool invocation completes as soon as both servers print their ready strings, and reports the URLs they really listen on. It fails fast when a server process exits before becoming ready, and otherwise after a 180 s timeout per server (override with `DMS_DEV_TIMEOUT`). The servers keep running detached in their own session (`setsid`, or perl's `POSIX::setsid` on macOS) so they survive the Bash call exiting.
|
|
91
|
+
|
|
92
|
+
`stop` and `restart` only touch the project in `DMS_BACK_DIR`: the pids the script starts are recorded in `DMS_BACK_DIR/.antelope/dms-dev.pid`, and without that file it only matches dev processes whose working directory or command line lies under `DMS_BACK_DIR`. Each process gets SIGTERM, then SIGKILL after `DMS_DEV_STOP_GRACE` seconds (default 10). Other projects' dev servers on the machine are never signalled.
|
|
82
93
|
|
|
83
94
|
## When to reach for the subcommands
|
|
84
95
|
|
|
85
|
-
- The user edits
|
|
86
|
-
- The user says "restart the dms", "restart servers", "reload", or "test it now". Run `restart
|
|
96
|
+
- The user edits a `.ts` or `.vue` file under the DMS backend package or under a DMS module's `src/` or `frontend-vue/` and wants to test the change live. Let hot reload apply it first; run `restart` only when it cannot (see above). From inside the DMS, never run it.
|
|
97
|
+
- The user says "restart the dms", "restart servers", "reload", or "test it now". Run `restart` -- unless you run inside the DMS, in which case say so and let the user restart.
|
|
87
98
|
- A browser smoke test fails in a way that looks like stale state. Run `restart` before diagnosing.
|
|
88
99
|
- The user asks "is the dms running?". Run `status`.
|
|
89
|
-
- A boot fails (backend never prints "Server started" or frontend never prints "
|
|
90
|
-
- **You are handing off to the user for manual testing** (e.g. a plan phase whose acceptance criterion is "user verifies in browser"). Run `restart` yourself first, wait for both ready strings, and only *then* tell the user what to click. Never ask the user to start the servers themselves — that's the whole reason this skill exists.
|
|
100
|
+
- A boot fails (backend never prints "Server started" or frontend never prints "Server ready on"). Print the relevant `back-log` / `front-log` tail to diagnose; common causes include `RegisterModule … already registered` (a previous stop failed — run `stop` then `restart`), port already in use (kill the listed PID), or a missing dependency (run `pnpm install` in the offending module).
|
|
101
|
+
- **You are handing off to the user for manual testing** (e.g. a plan phase whose acceptance criterion is "user verifies in browser"). Run `restart` yourself first, wait for both ready strings, and only *then* tell the user what to click. Never ask the user to start the servers themselves — that's the whole reason this skill exists. The one exception is an agent running inside the DMS, which already runs on live servers.
|
|
91
102
|
|
|
92
103
|
## Common pitfalls
|
|
93
104
|
|
|
94
|
-
- **Zombie frontend processes.** The frontend CLI
|
|
105
|
+
- **Zombie frontend processes.** The frontend CLI spawns child processes. The script's `stop` signals the whole session and process tree it started, and SIGKILLs whatever survives the grace period. If `start` reports servers already running, run `stop` and check that `status` shows `(none)` before starting again.
|
|
95
106
|
- **Backend exits with "Module already registered".** Means a previous instance is still holding the registration. Run `stop` to clear it, then `restart`.
|
|
96
107
|
- **Frontend's "auth redirect" 302** on `/modules/...` URLs is **normal** — it's the auth-protected admin routes. The frontend layer is loaded correctly even when curl returns 302. Don't restart on this alone; have the user log in.
|
|
97
108
|
- **Don't run `start` or `restart` more than once concurrently** in a single session — the second invocation will hit a port/lock conflict. If multiple background Bash tasks are queued, use the foreground `restart`.
|
|
@@ -101,7 +112,8 @@ The script writes the long-running server logs to `/tmp/dms-back.log` and `/tmp/
|
|
|
101
112
|
|
|
102
113
|
## Don't
|
|
103
114
|
|
|
104
|
-
- Don't
|
|
105
|
-
- Don't `
|
|
115
|
+
- Don't keep debugging a behaviour that looks stale after hot reload: that is the case for `restart` (from outside the DMS).
|
|
116
|
+
- Don't run `stop`, `restart` or `start` from an agent running inside the DMS (e.g. dms-ai): it would stop its own host.
|
|
117
|
+
- Don't `pkill` dev servers by name (`pkill -9 node`, `pkill -f antelope-runner`): other projects on the machine run the same processes. Use the script, which only signals the processes of `DMS_BACK_DIR`.
|
|
106
118
|
- Don't hardcode a backend path into a session. Always resolve via memory-or-prompt so the skill works across projects.
|
|
107
|
-
- Don't ask the user to start or restart the servers themselves before manual testing. You have the script — run it, wait for ready, then hand off. Asking the user to do it defeats the point of the skill and breaks the testing loop.
|
|
119
|
+
- Don't ask the user to start or restart the servers themselves before manual testing. You have the script — run it, wait for ready, then hand off. Asking the user to do it defeats the point of the skill and breaks the testing loop. (An agent inside the DMS is the exception: it never restarts its own host.)
|