@serverkgg/bridge 0.0.10 → 0.0.11

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.
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { BridgeSetupPromptKind, BridgeSetupStepState } from "../core";
3
3
  import type { BridgeSetupRuntime } from "../protocol";
4
- import { failedSetup, setupSummaryOf, unsupportedSetup } from "./bridgeSetup";
4
+ import { failedSetup, normalizeSetupRuntime, setupSummaryOf, unsupportedSetup } from "./bridgeSetup";
5
5
 
6
6
  const text = (value: string) => ({
7
7
  ar: value,
@@ -187,3 +187,389 @@ describe("a rejected submit", () => {
187
187
  });
188
188
  });
189
189
  });
190
+
191
+ const declared: ReadonlySet<string> = new Set([
192
+ "sign-in",
193
+ "download",
194
+ ]);
195
+
196
+ const normalizing = (runtime: BridgeSetupRuntime | null) => {
197
+ const reasons: string[] = [];
198
+
199
+ const normalized = normalizeSetupRuntime(runtime, {
200
+ declared,
201
+ warn: (_message, fields) => {
202
+ reasons.push(String(fields?.reason));
203
+ },
204
+ });
205
+
206
+ return {
207
+ normalized,
208
+ reasons,
209
+ };
210
+ };
211
+
212
+ const withSteps = (steps: BridgeSetupRuntime["steps"]): BridgeSetupRuntime => ({
213
+ steps,
214
+ prompt: null,
215
+ });
216
+
217
+ const link = (
218
+ prompt: Partial<
219
+ Extract<
220
+ BridgeSetupRuntime["prompt"],
221
+ {
222
+ kind: BridgeSetupPromptKind.Link;
223
+ }
224
+ >
225
+ >,
226
+ ) => {
227
+ return runtimeWith({
228
+ kind: BridgeSetupPromptKind.Link,
229
+ message: text("sign in with your account"),
230
+ url: "https://hytale.com/device",
231
+ code: "ABCD-EFGH",
232
+ expiresAt: null,
233
+ actions: [],
234
+ ...prompt,
235
+ });
236
+ };
237
+
238
+ describe("normalizing a reported runtime", () => {
239
+ test("has nothing to normalize while the driver reported no runtime", () => {
240
+ expect(
241
+ normalizeSetupRuntime(null, {
242
+ declared,
243
+ warn: () => {
244
+ return;
245
+ },
246
+ }),
247
+ ).toBeNull();
248
+ });
249
+
250
+ test("drops a step the driver never declared", () => {
251
+ const { normalized, reasons } = normalizing(
252
+ withSteps([
253
+ {
254
+ id: "sign-in",
255
+ state: BridgeSetupStepState.Active,
256
+ },
257
+ {
258
+ id: "invent",
259
+ state: BridgeSetupStepState.Pending,
260
+ },
261
+ ]),
262
+ );
263
+
264
+ expect(normalized?.steps).toEqual([
265
+ {
266
+ id: "sign-in",
267
+ state: BridgeSetupStepState.Active,
268
+ },
269
+ ]);
270
+ expect(reasons).toEqual([
271
+ "unknown-step",
272
+ ]);
273
+ });
274
+
275
+ test("drops the second report of the same step", () => {
276
+ const { normalized, reasons } = normalizing(
277
+ withSteps([
278
+ {
279
+ id: "sign-in",
280
+ state: BridgeSetupStepState.Done,
281
+ },
282
+ {
283
+ id: "sign-in",
284
+ state: BridgeSetupStepState.Failed,
285
+ },
286
+ ]),
287
+ );
288
+
289
+ expect(normalized?.steps).toEqual([
290
+ {
291
+ id: "sign-in",
292
+ state: BridgeSetupStepState.Done,
293
+ },
294
+ ]);
295
+ expect(reasons).toEqual([
296
+ "duplicate-step",
297
+ ]);
298
+ });
299
+
300
+ test("keeps the first active step and pends the rest", () => {
301
+ const { normalized, reasons } = normalizing(
302
+ withSteps([
303
+ {
304
+ id: "sign-in",
305
+ state: BridgeSetupStepState.Active,
306
+ },
307
+ {
308
+ id: "download",
309
+ state: BridgeSetupStepState.Active,
310
+ },
311
+ ]),
312
+ );
313
+
314
+ expect(normalized?.steps).toEqual([
315
+ {
316
+ id: "sign-in",
317
+ state: BridgeSetupStepState.Active,
318
+ },
319
+ {
320
+ id: "download",
321
+ state: BridgeSetupStepState.Pending,
322
+ },
323
+ ]);
324
+ expect(reasons).toEqual([
325
+ "extra-active-step",
326
+ ]);
327
+ });
328
+
329
+ test("clamps a percent that left the scale", () => {
330
+ expect(
331
+ normalizing(
332
+ runtimeWith({
333
+ kind: BridgeSetupPromptKind.Progress,
334
+ message: text("downloading the game"),
335
+ percent: 140,
336
+ detail: null,
337
+ }),
338
+ ),
339
+ ).toEqual({
340
+ normalized: {
341
+ steps: [
342
+ {
343
+ id: "sign-in",
344
+ state: BridgeSetupStepState.Active,
345
+ },
346
+ {
347
+ id: "download",
348
+ state: BridgeSetupStepState.Pending,
349
+ },
350
+ ],
351
+ prompt: {
352
+ kind: BridgeSetupPromptKind.Progress,
353
+ message: text("downloading the game"),
354
+ percent: 100,
355
+ detail: null,
356
+ },
357
+ },
358
+ reasons: [
359
+ "percent-out-of-range",
360
+ ],
361
+ });
362
+ });
363
+
364
+ test("drops a percent that is not a number", () => {
365
+ const { normalized, reasons } = normalizing(
366
+ runtimeWith({
367
+ kind: BridgeSetupPromptKind.Progress,
368
+ message: text("downloading the game"),
369
+ percent: Number.NaN,
370
+ detail: null,
371
+ }),
372
+ );
373
+
374
+ expect(normalized?.prompt).toEqual({
375
+ kind: BridgeSetupPromptKind.Progress,
376
+ message: text("downloading the game"),
377
+ percent: null,
378
+ detail: null,
379
+ });
380
+ expect(reasons).toEqual([
381
+ "unusable-percent",
382
+ ]);
383
+ });
384
+
385
+ test("cuts a detail line to the length the platform takes", () => {
386
+ const { normalized, reasons } = normalizing(
387
+ runtimeWith({
388
+ kind: BridgeSetupPromptKind.Progress,
389
+ message: text("downloading the game"),
390
+ percent: null,
391
+ detail: "d".repeat(300),
392
+ }),
393
+ );
394
+
395
+ expect(normalized?.prompt).toEqual({
396
+ kind: BridgeSetupPromptKind.Progress,
397
+ message: text("downloading the game"),
398
+ percent: null,
399
+ detail: "d".repeat(256),
400
+ });
401
+ expect(reasons).toEqual([
402
+ "long-detail",
403
+ ]);
404
+ });
405
+
406
+ test("drops an empty detail line", () => {
407
+ const { normalized, reasons } = normalizing(
408
+ runtimeWith({
409
+ kind: BridgeSetupPromptKind.Progress,
410
+ message: text("downloading the game"),
411
+ percent: null,
412
+ detail: "",
413
+ }),
414
+ );
415
+
416
+ expect(normalized?.prompt).toMatchObject({
417
+ detail: null,
418
+ });
419
+ expect(reasons).toEqual([
420
+ "empty-detail",
421
+ ]);
422
+ });
423
+
424
+ test("drops an action whose id is not kebab-case and the duplicate beside it", () => {
425
+ const { normalized, reasons } = normalizing(
426
+ runtimeWith({
427
+ kind: BridgeSetupPromptKind.Action,
428
+ message: text("get a new code"),
429
+ actions: [
430
+ {
431
+ id: "renew",
432
+ label: text("Get a new code"),
433
+ },
434
+ {
435
+ id: "Renew Now",
436
+ label: text("Get a new code"),
437
+ },
438
+ {
439
+ id: "renew",
440
+ label: text("Get a new code"),
441
+ },
442
+ ],
443
+ }),
444
+ );
445
+
446
+ expect(normalized?.prompt).toEqual({
447
+ kind: BridgeSetupPromptKind.Action,
448
+ message: text("get a new code"),
449
+ actions: [
450
+ {
451
+ id: "renew",
452
+ label: text("Get a new code"),
453
+ },
454
+ ],
455
+ });
456
+ expect(reasons).toEqual([
457
+ "unknown-action-id",
458
+ "duplicate-action",
459
+ ]);
460
+ });
461
+
462
+ test("keeps only as many actions as the platform renders", () => {
463
+ const { normalized, reasons } = normalizing(
464
+ runtimeWith({
465
+ kind: BridgeSetupPromptKind.Failed,
466
+ message: text("the sign-in expired"),
467
+ actions: Array.from({
468
+ length: 6,
469
+ }).map((_entry, index) => ({
470
+ id: `action-${index}`,
471
+ label: text(`Action ${index}`),
472
+ })),
473
+ }),
474
+ );
475
+
476
+ expect(normalized?.prompt?.kind).toBe(BridgeSetupPromptKind.Failed);
477
+ expect(normalized?.prompt && "actions" in normalized.prompt ? normalized.prompt.actions.length : 0).toBe(4);
478
+ expect(reasons).toEqual([
479
+ "extra-actions",
480
+ ]);
481
+ });
482
+
483
+ test("cuts a long code and drops an empty one", () => {
484
+ expect(
485
+ normalizing(
486
+ link({
487
+ code: "c".repeat(80),
488
+ }),
489
+ ).normalized?.prompt,
490
+ ).toMatchObject({
491
+ code: "c".repeat(64),
492
+ });
493
+
494
+ expect(
495
+ normalizing(
496
+ link({
497
+ code: "",
498
+ }),
499
+ ).reasons,
500
+ ).toEqual([
501
+ "empty-code",
502
+ ]);
503
+ });
504
+
505
+ test("re-emits an expiry as an iso stamp and drops one it cannot read", () => {
506
+ expect(
507
+ normalizing(
508
+ link({
509
+ expiresAt: "2026-09-04T10:00:00+03:00",
510
+ }),
511
+ ).normalized?.prompt,
512
+ ).toMatchObject({
513
+ expiresAt: "2026-09-04T07:00:00.000Z",
514
+ });
515
+
516
+ expect(
517
+ normalizing(
518
+ link({
519
+ expiresAt: "soon",
520
+ }),
521
+ ).reasons,
522
+ ).toEqual([
523
+ "unusable-expiry",
524
+ ]);
525
+ });
526
+
527
+ test("turns a link the browser cannot open into a failed prompt keeping its actions", () => {
528
+ const { normalized, reasons } = normalizing(
529
+ link({
530
+ url: "javascript:alert(1)",
531
+ actions: [
532
+ {
533
+ id: "renew",
534
+ label: text("Get a new code"),
535
+ },
536
+ ],
537
+ }),
538
+ );
539
+
540
+ expect(normalized?.prompt).toEqual({
541
+ kind: BridgeSetupPromptKind.Failed,
542
+ message: {
543
+ ar: "اللعبة رجّعت رابط دخول ما يشتغل. جرّب مرة ثانية.",
544
+ en: "The game reported a broken sign-in link. Try again.",
545
+ },
546
+ actions: [
547
+ {
548
+ id: "renew",
549
+ label: text("Get a new code"),
550
+ },
551
+ ],
552
+ });
553
+ expect(reasons).toEqual([
554
+ "unusable-link",
555
+ ]);
556
+ });
557
+
558
+ test("turns a url it cannot parse into a failed prompt", () => {
559
+ expect(
560
+ normalizing(
561
+ link({
562
+ url: "not a url",
563
+ }),
564
+ ).normalized?.prompt?.kind,
565
+ ).toBe(BridgeSetupPromptKind.Failed);
566
+ });
567
+
568
+ test("leaves a healthy runtime exactly as the driver reported it", () => {
569
+ const source = link({});
570
+ const { normalized, reasons } = normalizing(source);
571
+
572
+ expect(normalized).toEqual(source);
573
+ expect(reasons).toEqual([]);
574
+ });
575
+ });
@@ -1,11 +1,16 @@
1
- import { BridgeSetupPromptKind, BridgeSetupStepState, type LocalizedText } from "../core";
2
- import type {
3
- BridgeSetupAction,
4
- BridgeSetupPrompt,
5
- BridgeSetupResult,
6
- BridgeSetupRuntime,
7
- BridgeSetupSummary,
1
+ import { BRIDGE_ID_PATTERN, BridgeSetupPromptKind, BridgeSetupStepState, type LocalizedText } from "../core";
2
+ import {
3
+ BRIDGE_SETUP_ACTIONS_LIMIT,
4
+ BRIDGE_SETUP_CODE_LENGTH_LIMIT,
5
+ BRIDGE_SETUP_DETAIL_LENGTH_LIMIT,
6
+ type BridgeSetupAction,
7
+ type BridgeSetupPrompt,
8
+ type BridgeSetupResult,
9
+ type BridgeSetupRuntime,
10
+ type BridgeSetupStepStatus,
11
+ type BridgeSetupSummary,
8
12
  } from "../protocol";
13
+ import type { BridgeLogger } from "./bridgeLog";
9
14
 
10
15
  const ATTENTION_KINDS: ReadonlySet<BridgeSetupPromptKind> = new Set([
11
16
  BridgeSetupPromptKind.Link,
@@ -61,3 +66,238 @@ export const failedSetup = (runtime: BridgeSetupRuntime | null, text: LocalizedT
61
66
  },
62
67
  };
63
68
  };
69
+
70
+ const BROKEN_LINK_TEXT: LocalizedText = {
71
+ ar: "اللعبة رجّعت رابط دخول ما يشتغل. جرّب مرة ثانية.",
72
+ en: "The game reported a broken sign-in link. Try again.",
73
+ };
74
+
75
+ const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
76
+ "http:",
77
+ "https:",
78
+ ]);
79
+
80
+ export interface SetupNormalizeOptions {
81
+ declared: ReadonlySet<string>;
82
+ warn: BridgeLogger;
83
+ }
84
+
85
+ const httpUrl = (url: string) => {
86
+ try {
87
+ return LINK_PROTOCOLS.has(new URL(url).protocol);
88
+ } catch {
89
+ return false;
90
+ }
91
+ };
92
+
93
+ export const normalizeSetupRuntime = (
94
+ runtime: BridgeSetupRuntime | null,
95
+ options: SetupNormalizeOptions,
96
+ ): BridgeSetupRuntime | null => {
97
+ if (!runtime) {
98
+ return null;
99
+ }
100
+
101
+ const reported = new Set<string>();
102
+
103
+ const report = (reason: string) => {
104
+ if (reported.has(reason)) {
105
+ return;
106
+ }
107
+
108
+ reported.add(reason);
109
+
110
+ options.warn("setup runtime normalized", {
111
+ reason,
112
+ });
113
+ };
114
+
115
+ const steps: BridgeSetupStepStatus[] = [];
116
+ const seen = new Set<string>();
117
+
118
+ let active = false;
119
+
120
+ for (const step of runtime.steps) {
121
+ if (!options.declared.has(step.id)) {
122
+ report("unknown-step");
123
+
124
+ continue;
125
+ }
126
+
127
+ if (seen.has(step.id)) {
128
+ report("duplicate-step");
129
+
130
+ continue;
131
+ }
132
+
133
+ seen.add(step.id);
134
+
135
+ if (step.state !== BridgeSetupStepState.Active) {
136
+ steps.push(step);
137
+
138
+ continue;
139
+ }
140
+
141
+ if (active) {
142
+ report("extra-active-step");
143
+
144
+ steps.push({
145
+ id: step.id,
146
+ state: BridgeSetupStepState.Pending,
147
+ });
148
+
149
+ continue;
150
+ }
151
+
152
+ active = true;
153
+
154
+ steps.push(step);
155
+ }
156
+
157
+ return {
158
+ steps,
159
+ prompt: normalizePrompt(runtime.prompt, report),
160
+ };
161
+ };
162
+
163
+ const normalizeActions = (actions: BridgeSetupAction[], report: (reason: string) => void): BridgeSetupAction[] => {
164
+ const kept: BridgeSetupAction[] = [];
165
+ const seen = new Set<string>();
166
+
167
+ for (const action of actions) {
168
+ if (!BRIDGE_ID_PATTERN.test(action.id)) {
169
+ report("unknown-action-id");
170
+
171
+ continue;
172
+ }
173
+
174
+ if (seen.has(action.id)) {
175
+ report("duplicate-action");
176
+
177
+ continue;
178
+ }
179
+
180
+ seen.add(action.id);
181
+ kept.push(action);
182
+ }
183
+
184
+ if (kept.length <= BRIDGE_SETUP_ACTIONS_LIMIT) {
185
+ return kept;
186
+ }
187
+
188
+ report("extra-actions");
189
+
190
+ return kept.slice(0, BRIDGE_SETUP_ACTIONS_LIMIT);
191
+ };
192
+
193
+ const normalizePercent = (percent: number | null, report: (reason: string) => void): number | null => {
194
+ if (percent === null) {
195
+ return null;
196
+ }
197
+
198
+ if (!Number.isFinite(percent)) {
199
+ report("unusable-percent");
200
+
201
+ return null;
202
+ }
203
+
204
+ if (percent < 0 || percent > 100) {
205
+ report("percent-out-of-range");
206
+
207
+ return Math.min(Math.max(percent, 0), 100);
208
+ }
209
+
210
+ return percent;
211
+ };
212
+
213
+ const normalizeLine = (
214
+ value: string | null,
215
+ limit: number,
216
+ reason: string,
217
+ report: (reason: string) => void,
218
+ ): string | null => {
219
+ if (value === null) {
220
+ return null;
221
+ }
222
+
223
+ const sliced = value.slice(0, limit);
224
+
225
+ if (sliced.length === 0) {
226
+ report(`empty-${reason}`);
227
+
228
+ return null;
229
+ }
230
+
231
+ if (sliced.length !== value.length) {
232
+ report(`long-${reason}`);
233
+ }
234
+
235
+ return sliced;
236
+ };
237
+
238
+ const normalizeExpiry = (expiresAt: string | null, report: (reason: string) => void): string | null => {
239
+ if (expiresAt === null) {
240
+ return null;
241
+ }
242
+
243
+ const stamp = Date.parse(expiresAt);
244
+
245
+ if (!Number.isFinite(stamp)) {
246
+ report("unusable-expiry");
247
+
248
+ return null;
249
+ }
250
+
251
+ return new Date(stamp).toISOString();
252
+ };
253
+
254
+ const normalizePrompt = (
255
+ prompt: BridgeSetupPrompt | null,
256
+ report: (reason: string) => void,
257
+ ): BridgeSetupPrompt | null => {
258
+ if (!prompt) {
259
+ return null;
260
+ }
261
+
262
+ if (prompt.kind === BridgeSetupPromptKind.Wait) {
263
+ return prompt;
264
+ }
265
+
266
+ if (prompt.kind === BridgeSetupPromptKind.Progress) {
267
+ return {
268
+ kind: BridgeSetupPromptKind.Progress,
269
+ message: prompt.message,
270
+ percent: normalizePercent(prompt.percent, report),
271
+ detail: normalizeLine(prompt.detail, BRIDGE_SETUP_DETAIL_LENGTH_LIMIT, "detail", report),
272
+ };
273
+ }
274
+
275
+ const actions = normalizeActions(prompt.actions, report);
276
+
277
+ if (prompt.kind !== BridgeSetupPromptKind.Link) {
278
+ return {
279
+ kind: prompt.kind,
280
+ message: prompt.message,
281
+ actions,
282
+ };
283
+ }
284
+
285
+ if (!httpUrl(prompt.url)) {
286
+ report("unusable-link");
287
+
288
+ return {
289
+ kind: BridgeSetupPromptKind.Failed,
290
+ message: BROKEN_LINK_TEXT,
291
+ actions,
292
+ };
293
+ }
294
+
295
+ return {
296
+ kind: BridgeSetupPromptKind.Link,
297
+ message: prompt.message,
298
+ url: prompt.url,
299
+ code: normalizeLine(prompt.code, BRIDGE_SETUP_CODE_LENGTH_LIMIT, "code", report),
300
+ expiresAt: normalizeExpiry(prompt.expiresAt, report),
301
+ actions,
302
+ };
303
+ };