@vizamodo/modo-dispatcher 1.1.77

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 (109) hide show
  1. package/README.md +43 -0
  2. package/dist/artifacts/builders.d.ts +32 -0
  3. package/dist/artifacts/builders.js +72 -0
  4. package/dist/artifacts/downloader.d.ts +28 -0
  5. package/dist/artifacts/downloader.js +102 -0
  6. package/dist/artifacts/normalizer.d.ts +22 -0
  7. package/dist/artifacts/normalizer.js +65 -0
  8. package/dist/artifacts/types.d.ts +94 -0
  9. package/dist/artifacts/types.js +1 -0
  10. package/dist/auth/identity/extract-identity.d.ts +8 -0
  11. package/dist/auth/identity/extract-identity.js +15 -0
  12. package/dist/auth/identity/normalize-teams.d.ts +1 -0
  13. package/dist/auth/identity/normalize-teams.js +5 -0
  14. package/dist/auth/identity/validate-claims.d.ts +2 -0
  15. package/dist/auth/identity/validate-claims.js +5 -0
  16. package/dist/auth/jwt/parse.d.ts +2 -0
  17. package/dist/auth/jwt/parse.js +16 -0
  18. package/dist/auth/jwt/verify.d.ts +1 -0
  19. package/dist/auth/jwt/verify.js +9 -0
  20. package/dist/auth/oauth/auth-error.d.ts +12 -0
  21. package/dist/auth/oauth/auth-error.js +47 -0
  22. package/dist/auth/oauth/auth-session-store.d.ts +30 -0
  23. package/dist/auth/oauth/auth-session-store.js +46 -0
  24. package/dist/auth/oauth/callback-server.d.ts +21 -0
  25. package/dist/auth/oauth/callback-server.js +319 -0
  26. package/dist/auth/oauth/github.d.ts +14 -0
  27. package/dist/auth/oauth/github.js +100 -0
  28. package/dist/auth/oauth/sse.d.ts +11 -0
  29. package/dist/auth/oauth/sse.js +67 -0
  30. package/dist/auth/oauth/templates/failed.html +629 -0
  31. package/dist/auth/oauth/templates/pending.html +620 -0
  32. package/dist/auth/oauth/templates/success.html +577 -0
  33. package/dist/auth/session/access-token.d.ts +15 -0
  34. package/dist/auth/session/access-token.js +64 -0
  35. package/dist/auth/session/login.d.ts +18 -0
  36. package/dist/auth/session/login.js +144 -0
  37. package/dist/auth/session/logout.d.ts +3 -0
  38. package/dist/auth/session/logout.js +21 -0
  39. package/dist/auth/session/refresh-token.d.ts +8 -0
  40. package/dist/auth/session/refresh-token.js +16 -0
  41. package/dist/auth/session/refresh.d.ts +2 -0
  42. package/dist/auth/session/refresh.js +61 -0
  43. package/dist/auth/session/rotate.d.ts +4 -0
  44. package/dist/auth/session/rotate.js +4 -0
  45. package/dist/auth/session/session-manager.d.ts +16 -0
  46. package/dist/auth/session/session-manager.js +54 -0
  47. package/dist/auth/session/token-state.d.ts +20 -0
  48. package/dist/auth/session/token-state.js +55 -0
  49. package/dist/auth/session/types.d.ts +35 -0
  50. package/dist/auth/session/types.js +1 -0
  51. package/dist/auth/storage/keychain.d.ts +16 -0
  52. package/dist/auth/storage/keychain.js +107 -0
  53. package/dist/auth/storage/memory.d.ts +10 -0
  54. package/dist/auth/storage/memory.js +15 -0
  55. package/dist/auth/storage/types.d.ts +5 -0
  56. package/dist/auth/storage/types.js +1 -0
  57. package/dist/config/defaults.d.ts +94 -0
  58. package/dist/config/defaults.js +116 -0
  59. package/dist/core/auth-bootstrap.d.ts +15 -0
  60. package/dist/core/auth-bootstrap.js +33 -0
  61. package/dist/core/bootstrap.d.ts +73 -0
  62. package/dist/core/bootstrap.js +248 -0
  63. package/dist/core/dispatcher.d.ts +2 -0
  64. package/dist/core/dispatcher.js +28 -0
  65. package/dist/core/ensure-bootstrap.d.ts +3 -0
  66. package/dist/core/ensure-bootstrap.js +25 -0
  67. package/dist/core/errors.d.ts +69 -0
  68. package/dist/core/errors.js +121 -0
  69. package/dist/core/runtime-orchestrator.d.ts +17 -0
  70. package/dist/core/runtime-orchestrator.js +47 -0
  71. package/dist/core/runtime.d.ts +16 -0
  72. package/dist/core/runtime.js +52 -0
  73. package/dist/core/validation.d.ts +2 -0
  74. package/dist/core/validation.js +21 -0
  75. package/dist/crypto/encrypt.d.ts +12 -0
  76. package/dist/crypto/encrypt.js +80 -0
  77. package/dist/flows/email-otp-flow.d.ts +7 -0
  78. package/dist/flows/email-otp-flow.js +249 -0
  79. package/dist/gateway/auth-header.d.ts +2 -0
  80. package/dist/gateway/auth-header.js +21 -0
  81. package/dist/gateway/client.d.ts +7 -0
  82. package/dist/gateway/client.js +24 -0
  83. package/dist/gateway/dispatch-with-bootstrap-retry.d.ts +25 -0
  84. package/dist/gateway/dispatch-with-bootstrap-retry.js +157 -0
  85. package/dist/gateway/dispatch.d.ts +23 -0
  86. package/dist/gateway/dispatch.js +110 -0
  87. package/dist/gateway/session-waiter.d.ts +11 -0
  88. package/dist/gateway/session-waiter.js +126 -0
  89. package/dist/gateway/session.d.ts +21 -0
  90. package/dist/gateway/session.js +74 -0
  91. package/dist/gateway/types.d.ts +113 -0
  92. package/dist/gateway/types.js +19 -0
  93. package/dist/index.d.ts +22 -0
  94. package/dist/index.js +21 -0
  95. package/dist/runtime/runtime-context.d.ts +6 -0
  96. package/dist/runtime/runtime-context.js +1 -0
  97. package/dist/types/dispatcher.d.ts +275 -0
  98. package/dist/types/dispatcher.js +1 -0
  99. package/dist/types/payload.d.ts +5 -0
  100. package/dist/types/payload.js +2 -0
  101. package/dist/types/runtime-env.d.ts +2 -0
  102. package/dist/types/runtime-env.js +5 -0
  103. package/dist/ui/banner.d.ts +4 -0
  104. package/dist/ui/banner.js +20 -0
  105. package/dist/utils/request-dedup.d.ts +75 -0
  106. package/dist/utils/request-dedup.js +102 -0
  107. package/dist/utils/type-guards.d.ts +173 -0
  108. package/dist/utils/type-guards.js +232 -0
  109. package/package.json +43 -0
@@ -0,0 +1,46 @@
1
+ export class AuthSessionStore {
2
+ static SESSION_TTL_MS = 1000 * 60 * 10;
3
+ sessions = new Map();
4
+ create(sessionId) {
5
+ const now = Date.now();
6
+ const session = {
7
+ sessionId,
8
+ status: "pending",
9
+ createdAt: now,
10
+ updatedAt: now,
11
+ };
12
+ this.sessions.set(sessionId, session);
13
+ setTimeout(() => {
14
+ this.sessions.delete(sessionId);
15
+ }, AuthSessionStore.SESSION_TTL_MS);
16
+ return session;
17
+ }
18
+ get(sessionId) {
19
+ return this.sessions.get(sessionId);
20
+ }
21
+ applyResult(payload) {
22
+ const session = this.sessions.get(payload.sessionId);
23
+ if (!session)
24
+ return undefined;
25
+ if (session.status !== "pending")
26
+ return session;
27
+ session.status = payload.status;
28
+ if (payload.login !== undefined)
29
+ session.login = payload.login;
30
+ if (payload.email !== undefined)
31
+ session.email = payload.email;
32
+ if (payload.avatarUrl !== undefined)
33
+ session.avatarUrl = payload.avatarUrl;
34
+ if (payload.teams !== undefined)
35
+ session.teams = payload.teams;
36
+ if (payload.error !== undefined) {
37
+ // (debug logging removed)
38
+ session.error = payload.error;
39
+ }
40
+ session.updatedAt = Date.now();
41
+ return session;
42
+ }
43
+ delete(sessionId) {
44
+ this.sessions.delete(sessionId);
45
+ }
46
+ }
@@ -0,0 +1,21 @@
1
+ import { AuthSessionResultPayload } from "./auth-session-store.js";
2
+ export interface OAuthCallbackResult {
3
+ code: string;
4
+ state?: string;
5
+ sessionId: string;
6
+ port: number;
7
+ notifyAuthResult: (payload: Omit<AuthSessionResultPayload, "sessionId">) => Promise<void>;
8
+ close: () => Promise<void>;
9
+ }
10
+ export interface OAuthCallbackServer {
11
+ port: number;
12
+ waitForCallback(): Promise<OAuthCallbackResult>;
13
+ close(): Promise<void>;
14
+ }
15
+ export declare function createOAuthCallbackServer(preferredPort: number, callbackPath?: string): Promise<OAuthCallbackServer>;
16
+ /**
17
+ * Backward-compatible helper: start the callback server and wait for the
18
+ * OAuth callback in one call. Prefer {@link createOAuthCallbackServer}
19
+ * when you need to know the bound port before opening the browser.
20
+ */
21
+ export declare function waitForOAuthCallback(preferredPort: number, callbackPath?: string): Promise<OAuthCallbackResult>;
@@ -0,0 +1,319 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { URL } from "node:url";
7
+ import { AuthSessionStore, } from "./auth-session-store.js";
8
+ import { SseHub } from "./sse.js";
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const pendingTemplate = fs.readFileSync(path.join(__dirname, "templates", "pending.html"), "utf8");
12
+ const successTemplate = fs.readFileSync(path.join(__dirname, "templates", "success.html"), "utf8");
13
+ const failedTemplate = fs.readFileSync(path.join(__dirname, "templates", "failed.html"), "utf8");
14
+ function escapeHtml(value) {
15
+ // Build HTML entities via char codes to avoid template parsing issues
16
+ const AMP = String.fromCharCode(38) + "amp;";
17
+ const LT = String.fromCharCode(38) + "lt;";
18
+ const GT = String.fromCharCode(38) + "gt;";
19
+ const QUOT = String.fromCharCode(38) + "quot;";
20
+ return value
21
+ .replace(/&/g, AMP)
22
+ .replace(/</g, LT)
23
+ .replace(/>/g, GT)
24
+ .replace(/"/g, QUOT)
25
+ .replace(/'/g, "&#39;");
26
+ }
27
+ async function parseJsonBody(req) {
28
+ const chunks = [];
29
+ for await (const chunk of req) {
30
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
31
+ }
32
+ const raw = Buffer.concat(chunks).toString("utf8");
33
+ return raw ? JSON.parse(raw) : {};
34
+ }
35
+ function isStructuredAuthError(value) {
36
+ if (!value || typeof value !== "object")
37
+ return false;
38
+ const candidate = value;
39
+ return typeof candidate.code === "string" && typeof candidate.message === "string";
40
+ }
41
+ function isAuthSessionResultPayload(value) {
42
+ if (!value || typeof value !== "object")
43
+ return false;
44
+ const candidate = value;
45
+ if (typeof candidate.sessionId !== "string" || candidate.sessionId.length === 0) {
46
+ return false;
47
+ }
48
+ if (candidate.status !== "success" && candidate.status !== "failed") {
49
+ return false;
50
+ }
51
+ if (candidate.status === "failed" && !isStructuredAuthError(candidate.error)) {
52
+ return false;
53
+ }
54
+ return true;
55
+ }
56
+ function renderSuccess(session) {
57
+ const login = escapeHtml(session?.login ?? "unknown");
58
+ const email = escapeHtml(typeof session?.email === "string" && session.email.length > 0
59
+ ? session.email
60
+ : "🔒 Protected by GitHub");
61
+ const avatarUrl = escapeHtml(session?.avatarUrl ?? "");
62
+ const teams = escapeHtml(session?.teams?.join(", ") ?? "n/a");
63
+ return successTemplate
64
+ .replaceAll("__USERNAME__", login)
65
+ .replaceAll("__EMAIL__", email)
66
+ .replaceAll("__AVATAR_URL__", avatarUrl)
67
+ .replaceAll("__TEAMS__", teams);
68
+ }
69
+ function renderFailed(session) {
70
+ const error = session?.error;
71
+ // (debug logging removed)
72
+ const errorCode = safeDisplayText(error?.code);
73
+ const errorMessage = safeDisplayText(error?.message);
74
+ const githubLogin = safeDisplayText(error?.githubLogin);
75
+ const githubUserId = safeDisplayText(typeof error?.githubUserId === "number" ? String(error.githubUserId) : undefined);
76
+ const suggestion = safeDisplayText(error?.suggestion);
77
+ return failedTemplate
78
+ .replaceAll("__ERROR_CODE__", escapeHtml(errorCode))
79
+ .replaceAll("__ERROR_MESSAGE__", escapeHtml(errorMessage))
80
+ .replaceAll("__GITHUB_LOGIN__", escapeHtml(githubLogin))
81
+ .replaceAll("__GITHUB_USER_ID__", escapeHtml(githubUserId))
82
+ .replaceAll("__SUGGESTION__", escapeHtml(suggestion));
83
+ }
84
+ function safeDisplayText(value) {
85
+ if (typeof value !== "string") {
86
+ return "-";
87
+ }
88
+ const trimmed = value.trim();
89
+ return trimmed.length > 0 ? trimmed : "-";
90
+ }
91
+ export function createOAuthCallbackServer(preferredPort, callbackPath = "/callback") {
92
+ return new Promise((resolveServer, rejectServer) => {
93
+ const store = new AuthSessionStore();
94
+ const sse = new SseHub();
95
+ let callbackResolved = false;
96
+ let actualPort = preferredPort;
97
+ const sockets = new Set();
98
+ let callbackResolve;
99
+ let callbackReject;
100
+ const waitForCallback = () => new Promise((res, rej) => {
101
+ callbackResolve = res;
102
+ callbackReject = rej;
103
+ });
104
+ const closeServer = async (options) => {
105
+ const delayMs = options?.delayMs ?? 0;
106
+ if (delayMs > 0) {
107
+ await new Promise((resolveDelay) => {
108
+ setTimeout(resolveDelay, delayMs);
109
+ });
110
+ }
111
+ sse.closeAll();
112
+ await new Promise((closeResolve) => {
113
+ server.close(() => {
114
+ for (const socket of sockets) {
115
+ socket.destroy();
116
+ }
117
+ sockets.clear();
118
+ closeResolve();
119
+ });
120
+ });
121
+ };
122
+ const server = http.createServer(async (req, res) => {
123
+ try {
124
+ if (!req.url) {
125
+ res.statusCode = 400;
126
+ res.end("missing url");
127
+ return;
128
+ }
129
+ const url = new URL(req.url, `http://127.0.0.1:${actualPort}`);
130
+ if (req.method === "GET" && url.pathname === callbackPath) {
131
+ const code = url.searchParams.get("code");
132
+ const state = url.searchParams.get("state") ?? undefined;
133
+ if (!code) {
134
+ res.statusCode = 400;
135
+ res.end("missing code");
136
+ return;
137
+ }
138
+ const sessionId = randomBytes(12).toString("hex");
139
+ store.create(sessionId);
140
+ res.statusCode = 200;
141
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
142
+ res.end(pendingTemplate.replaceAll("__SESSION_ID__", sessionId));
143
+ if (!callbackResolved) {
144
+ callbackResolved = true;
145
+ callbackResolve({
146
+ code,
147
+ ...(state ? { state } : {}),
148
+ sessionId,
149
+ port: actualPort,
150
+ notifyAuthResult: async (payload) => {
151
+ const body = {
152
+ sessionId,
153
+ ...payload,
154
+ };
155
+ await fetch(`http://127.0.0.1:${actualPort}/auth-result`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify(body),
159
+ });
160
+ },
161
+ close: () => closeServer({ delayMs: 5000 }),
162
+ });
163
+ }
164
+ return;
165
+ }
166
+ if (req.method === "GET" && url.pathname === "/events") {
167
+ const sessionId = url.searchParams.get("sessionId");
168
+ if (!sessionId) {
169
+ res.statusCode = 400;
170
+ res.end("missing sessionId");
171
+ return;
172
+ }
173
+ const session = store.get(sessionId);
174
+ if (!session) {
175
+ res.statusCode = 404;
176
+ res.end("session not found");
177
+ return;
178
+ }
179
+ sse.addClient(sessionId, res);
180
+ if (session.status === "success") {
181
+ sse.emit(sessionId, "success", {
182
+ success: true,
183
+ login: session.login,
184
+ email: session.email,
185
+ avatarUrl: session.avatarUrl,
186
+ teams: session.teams,
187
+ });
188
+ }
189
+ if (session.status === "failed") {
190
+ sse.emit(sessionId, "failed", {
191
+ success: false,
192
+ error: session.error,
193
+ });
194
+ }
195
+ return;
196
+ }
197
+ if (req.method === "POST" && url.pathname === "/auth-result") {
198
+ const body = await parseJsonBody(req);
199
+ if (!isAuthSessionResultPayload(body)) {
200
+ console.error("[auth] malformed /auth-result payload", {
201
+ received: body,
202
+ });
203
+ res.statusCode = 400;
204
+ res.end("invalid payload");
205
+ return;
206
+ }
207
+ const session = store.applyResult(body);
208
+ if (!session) {
209
+ res.statusCode = 404;
210
+ res.end("session not found");
211
+ return;
212
+ }
213
+ if (session.status === "success") {
214
+ sse.emit(session.sessionId, "success", {
215
+ success: true,
216
+ login: session.login,
217
+ email: session.email,
218
+ avatarUrl: session.avatarUrl,
219
+ teams: session.teams,
220
+ });
221
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
222
+ res.end(JSON.stringify({
223
+ success: true,
224
+ login: session.login ?? "",
225
+ email: session.email ?? "",
226
+ avatarUrl: session.avatarUrl ?? "",
227
+ teams: session.teams ?? [],
228
+ }));
229
+ return;
230
+ }
231
+ sse.emit(session.sessionId, "failed", {
232
+ success: false,
233
+ error: session.error,
234
+ });
235
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
236
+ res.end(JSON.stringify({
237
+ success: false,
238
+ error: session.error,
239
+ }));
240
+ return;
241
+ }
242
+ if (req.method === "GET" && url.pathname === "/success") {
243
+ const sessionId = url.searchParams.get("sessionId") ?? "";
244
+ const session = store.get(sessionId);
245
+ if (!session) {
246
+ res.statusCode = 404;
247
+ res.end("session not found");
248
+ return;
249
+ }
250
+ res.statusCode = 200;
251
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
252
+ res.end(renderSuccess(session));
253
+ store.delete(sessionId);
254
+ setTimeout(() => {
255
+ void closeServer();
256
+ }, 100);
257
+ return;
258
+ }
259
+ if (req.method === "GET" && url.pathname === "/failed") {
260
+ const sessionId = url.searchParams.get("sessionId") ?? "";
261
+ const session = store.get(sessionId);
262
+ if (!session) {
263
+ res.statusCode = 404;
264
+ res.end("session not found");
265
+ return;
266
+ }
267
+ res.statusCode = 200;
268
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
269
+ res.end(renderFailed(session));
270
+ store.delete(sessionId);
271
+ setTimeout(() => {
272
+ void closeServer();
273
+ }, 100);
274
+ return;
275
+ }
276
+ res.statusCode = 404;
277
+ res.end("not found");
278
+ }
279
+ catch (error) {
280
+ res.statusCode = 500;
281
+ res.end("internal error");
282
+ }
283
+ });
284
+ server.on("connection", (socket) => {
285
+ sockets.add(socket);
286
+ socket.on("close", () => sockets.delete(socket));
287
+ });
288
+ server.on("error", (err) => {
289
+ if (actualPort !== 0 && "code" in err && err.code === "EADDRINUSE") {
290
+ console.warn(`[auth] port ${actualPort} in use, retrying with random port...`);
291
+ actualPort = 0;
292
+ server.listen(0, "127.0.0.1");
293
+ return;
294
+ }
295
+ rejectServer(err);
296
+ });
297
+ server.on("listening", () => {
298
+ const address = server.address();
299
+ if (address && typeof address === "object") {
300
+ actualPort = address.port;
301
+ }
302
+ resolveServer({
303
+ port: actualPort,
304
+ waitForCallback,
305
+ close: () => closeServer(),
306
+ });
307
+ });
308
+ server.listen(actualPort, "127.0.0.1");
309
+ });
310
+ }
311
+ /**
312
+ * Backward-compatible helper: start the callback server and wait for the
313
+ * OAuth callback in one call. Prefer {@link createOAuthCallbackServer}
314
+ * when you need to know the bound port before opening the browser.
315
+ */
316
+ export async function waitForOAuthCallback(preferredPort, callbackPath = "/callback") {
317
+ const srv = await createOAuthCallbackServer(preferredPort, callbackPath);
318
+ return srv.waitForCallback();
319
+ }
@@ -0,0 +1,14 @@
1
+ import { TokenPair } from "../session/types.js";
2
+ import { GatewayBootstrapMap } from "../../core/bootstrap.js";
3
+ export interface ExchangeGithubCodeOptions {
4
+ exchangeEndpoint: string;
5
+ code: string;
6
+ state?: string;
7
+ codeVerifier?: string;
8
+ redirectUri?: string;
9
+ }
10
+ export interface ExchangeResult {
11
+ tokenPair: TokenPair;
12
+ bootstrap?: GatewayBootstrapMap | undefined;
13
+ }
14
+ export declare function exchangeGithubCodeForTokens(options: ExchangeGithubCodeOptions): Promise<ExchangeResult>;
@@ -0,0 +1,100 @@
1
+ import { AuthError } from "./auth-error.js";
2
+ export async function exchangeGithubCodeForTokens(options) {
3
+ let res;
4
+ try {
5
+ res = await fetch(options.exchangeEndpoint, {
6
+ method: "POST",
7
+ headers: { "Content-Type": "application/json" },
8
+ body: JSON.stringify({
9
+ code: options.code,
10
+ state: options.state,
11
+ code_verifier: options.codeVerifier,
12
+ redirect_uri: options.redirectUri,
13
+ }),
14
+ });
15
+ }
16
+ catch {
17
+ throw new AuthError({
18
+ code: "authentication_failed",
19
+ message: "Authentication could not be completed.",
20
+ suggestion: "Retry login again.",
21
+ });
22
+ }
23
+ let json;
24
+ try {
25
+ json = await res.json();
26
+ }
27
+ catch {
28
+ throw new AuthError({
29
+ code: "authentication_failed",
30
+ message: "Authentication could not be completed.",
31
+ suggestion: "Retry login again.",
32
+ });
33
+ }
34
+ // (debug logging removed)
35
+ if (!isExchangeResponse(json)) {
36
+ throw new AuthError({
37
+ code: "authentication_failed",
38
+ message: "Authentication could not be completed.",
39
+ suggestion: "Retry login again.",
40
+ });
41
+ }
42
+ if (json.success === false) {
43
+ throw new AuthError(json.error);
44
+ }
45
+ const accessToken = typeof json.access_token === "string"
46
+ ? json.access_token
47
+ : typeof json.accessToken === "string"
48
+ ? json.accessToken
49
+ : undefined;
50
+ const refreshToken = typeof json.refresh_token === "string"
51
+ ? json.refresh_token
52
+ : typeof json.refreshToken === "string"
53
+ ? json.refreshToken
54
+ : undefined;
55
+ if (!accessToken || !refreshToken) {
56
+ throw new AuthError({
57
+ code: "authentication_failed",
58
+ message: "Authentication could not be completed.",
59
+ suggestion: "Retry login again.",
60
+ });
61
+ }
62
+ const accessTokenExpiresAt = toMillis(json.accessTokenExpiresAt);
63
+ const refreshTokenExpiresAt = toMillis(json.refreshTokenExpiresAt);
64
+ const tokenPair = {
65
+ accessToken,
66
+ refreshToken,
67
+ tokenType: "Bearer",
68
+ ...(accessTokenExpiresAt ? { accessTokenExpiresAt } : {}),
69
+ ...(refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}),
70
+ };
71
+ return {
72
+ tokenPair,
73
+ bootstrap: json.bootstrap,
74
+ };
75
+ }
76
+ function isExchangeResponse(value) {
77
+ if (!value || typeof value !== "object")
78
+ return false;
79
+ const candidate = value;
80
+ if (candidate["success"] === false) {
81
+ if (!candidate["error"] || typeof candidate["error"] !== "object")
82
+ return false;
83
+ const error = candidate["error"];
84
+ return typeof error["code"] === "string" && typeof error["message"] === "string";
85
+ }
86
+ if (candidate["success"] === true) {
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ function toMillis(value) {
92
+ if (typeof value === "number") {
93
+ return value > 1e12 ? value : value * 1000;
94
+ }
95
+ if (typeof value === "string") {
96
+ const ts = Date.parse(value);
97
+ return Number.isNaN(ts) ? undefined : ts;
98
+ }
99
+ return undefined;
100
+ }
@@ -0,0 +1,11 @@
1
+ import http from "node:http";
2
+ export type SseEventName = "success" | "failed";
3
+ export declare class SseHub {
4
+ private readonly clients;
5
+ private heartbeatTimer;
6
+ addClient(sessionId: string, res: http.ServerResponse): void;
7
+ emit(sessionId: string, event: SseEventName, payload: unknown): void;
8
+ closeAll(): void;
9
+ private ensureHeartbeat;
10
+ private stopHeartbeatIfIdle;
11
+ }
@@ -0,0 +1,67 @@
1
+ export class SseHub {
2
+ clients = new Map();
3
+ heartbeatTimer = null;
4
+ addClient(sessionId, res) {
5
+ res.statusCode = 200;
6
+ res.setHeader("Content-Type", "text/event-stream");
7
+ res.setHeader("Cache-Control", "no-cache, no-transform");
8
+ res.setHeader("Connection", "keep-alive");
9
+ res.setHeader("X-Accel-Buffering", "no");
10
+ res.flushHeaders();
11
+ res.write(": connected\n\n");
12
+ let set = this.clients.get(sessionId);
13
+ if (!set) {
14
+ set = new Set();
15
+ this.clients.set(sessionId, set);
16
+ }
17
+ set.add(res);
18
+ this.ensureHeartbeat();
19
+ res.on("close", () => {
20
+ const current = this.clients.get(sessionId);
21
+ if (!current)
22
+ return;
23
+ current.delete(res);
24
+ if (current.size === 0) {
25
+ this.clients.delete(sessionId);
26
+ }
27
+ });
28
+ }
29
+ emit(sessionId, event, payload) {
30
+ const set = this.clients.get(sessionId);
31
+ if (!set || set.size === 0)
32
+ return;
33
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
34
+ for (const res of set) {
35
+ res.write(data);
36
+ res.end();
37
+ }
38
+ this.clients.delete(sessionId);
39
+ this.stopHeartbeatIfIdle();
40
+ }
41
+ closeAll() {
42
+ for (const set of this.clients.values()) {
43
+ for (const res of set) {
44
+ res.end();
45
+ }
46
+ }
47
+ this.clients.clear();
48
+ this.stopHeartbeatIfIdle();
49
+ }
50
+ ensureHeartbeat() {
51
+ if (this.heartbeatTimer)
52
+ return;
53
+ this.heartbeatTimer = setInterval(() => {
54
+ for (const set of this.clients.values()) {
55
+ for (const res of set) {
56
+ res.write(": ping\n\n");
57
+ }
58
+ }
59
+ }, 15_000);
60
+ }
61
+ stopHeartbeatIfIdle() {
62
+ if (this.clients.size !== 0 || !this.heartbeatTimer)
63
+ return;
64
+ clearInterval(this.heartbeatTimer);
65
+ this.heartbeatTimer = null;
66
+ }
67
+ }