dredless 0.6.1 → 0.6.3

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.
@@ -99,6 +99,29 @@ interface Ship {
99
99
  }
100
100
  ```
101
101
 
102
+ Ship names are returned exactly as provided by the Drednot server. In
103
+ particular, `fetchShips()` and `fetchShipList()` preserve leading and trailing
104
+ spaces; it is up to callers to optionally trim those spaces from `name`.
105
+
106
+ ### `InviteMetadata`
107
+
108
+ Returned by `fetchInviteMetadata()`.
109
+
110
+ ```ts
111
+ interface InviteMetadata {
112
+ shipId: string | null;
113
+ name: string | null;
114
+ previewImageUrl: string | null;
115
+ hostname: string;
116
+ inviteCode: string;
117
+ color?: string | null;
118
+ }
119
+ ```
120
+
121
+ Invite metadata also preserves leading and trailing spaces in the
122
+ ship name returned by the invite page. The optional `color` field is included
123
+ only when `includeColor: true` is passed and is sampled from the preview image.
124
+
102
125
  ### `ShipList`
103
126
 
104
127
  Returned by `fetchShipList()`.
@@ -302,6 +325,59 @@ once(type: string, callback: (...args: unknown[]) => void): this;
302
325
 
303
326
  Events are implementation-defined strings emitted by the client event bus.
304
327
 
328
+ ### Context menus: `client.tips`
329
+
330
+ ```js
331
+ const door = client.currentShip().machines.doors()[0];
332
+ const menu = await door.openTips();
333
+ console.log(menu.options);
334
+ client.tips.select("Restrict to Crew"); // Also accepts a zero-based option index.
335
+
336
+ // Open and select in one operation:
337
+ await client.tips.choose(door, "Restrict to Captains");
338
+
339
+ // Dismiss without selecting an option:
340
+ await door.openTips();
341
+ await client.tips.close();
342
+
343
+ // Set access and wait for the server's model update:
344
+ await door.setRank("guest");
345
+ await client.currentShip().machines.spawnPoints()[0].setRank("crew");
346
+ ```
347
+
348
+ `client.tips.open(entity, options?)` accepts an entity handle or an entity id in
349
+ the current world and resolves to a frozen `TipList` with
350
+ `{ entity, world, filter, x, y, options }`. Every entity handle also exposes
351
+ `openTips(options?)`. `client.tips.current` is the latest menu, or `null` after
352
+ selection, an empty menu, or disconnect. The `tip-list` event receives
353
+ `(menu, world)` including empty menus; the existing raw `side-event` is preserved.
354
+ The server does not send an entity id in a tip list: `entity` is inferred from
355
+ the API's requested target and is `null` for unsolicited menus. Avoid mixing raw
356
+ context-menu commands with an API operation in progress.
357
+
358
+ `select(option, command?)` sends the selected index and returns the client.
359
+ `choose(entity, option, options?)` opens a fresh menu and selects by exact text
360
+ or index, resolving to the client once selection is sent. Neither method waits
361
+ for the resulting action to complete.
362
+
363
+ `close(options?)` sends the captured left-click dismissal input with no focused
364
+ entity or selected option, then resolves to the client after the server sends an
365
+ empty tip list for the menu's world. It skips sending when no menu is open and
366
+ uses the same timeout and overlap rules as `open()`. Optional `command` fields
367
+ can supply click coordinates or inventory/view settings.
368
+
369
+ Door and spawn-point `setRank(rank, options?)` accept `"guest"`, `"crew"`,
370
+ `"captain"`, or `0`, `1`, `3`. They resolve to the client after the model confirms
371
+ the requested rank, and skip sending when the rank already matches. These are
372
+ access restrictions; they do not change a player's ship rank.
373
+
374
+ Options are `{ timeout?: number, command?: Command }`. The timeout defaults to
375
+ 5000 milliseconds per response (menu and, for `setRank`, rank confirmation).
376
+ Calls reject on timeout, disconnect, unknown options, invalid ranks, or a target
377
+ outside the current world. Only one asynchronous tip operation may run per
378
+ client; overlapping calls reject. The server still enforces permissions and
379
+ interaction range; these helpers do not move the player or grant access.
380
+
305
381
  ## `client.state()` Output
306
382
 
307
383
  ```ts
package/docs/NODE_API.md CHANGED
@@ -44,6 +44,7 @@ interface DredlessNamespace {
44
44
  fetchServers: typeof fetchServers;
45
45
  fetchShips: typeof fetchShips;
46
46
  fetchShipList: typeof fetchShipList;
47
+ fetchInviteMetadata: typeof fetchInviteMetadata;
47
48
  joinShip: typeof joinShip;
48
49
  startShip: typeof startShip;
49
50
  startNewShip: typeof startNewShip;
@@ -133,6 +134,9 @@ function fetchShips(session: Session, server: ServerRef): Promise<Ship[]>;
133
134
 
134
135
  Authenticated convenience wrapper around `session.fetchShips(server)`.
135
136
 
137
+ Ship names in the returned objects are canonical Drednot server data. Leading
138
+ and trailing spaces are preserved exactly; callers should not trim them.
139
+
136
140
  ### `Dredless.fetchShipList(session, server)`
137
141
 
138
142
  ```ts
@@ -141,6 +145,22 @@ function fetchShipList(session: Session, server: ServerRef): Promise<ShipList>;
141
145
 
142
146
  Authenticated convenience wrapper around `session.fetchShipList(server)`.
143
147
 
148
+ Ship names in the returned objects are canonical Drednot server data. Leading
149
+ and trailing spaces are preserved exactly; callers should not trim them.
150
+
151
+ ### `Dredless.fetchInviteMetadata(invite, options?)`
152
+
153
+ ```ts
154
+ function fetchInviteMetadata(
155
+ invite: string | URL,
156
+ options?: { includeColor?: boolean }
157
+ ): Promise<InviteMetadata>;
158
+ ```
159
+
160
+ Fetches and parses an invite page. The returned ship name preserves leading
161
+ and trailing spaces exactly as returned by Drednot. Set `includeColor: true`
162
+ to sample the preview image’s top-left pixel; otherwise no image is fetched.
163
+
144
164
  ### `Dredless.joinShip(server, ship?, session?)`
145
165
 
146
166
  ```ts
@@ -371,4 +391,3 @@ interface ConnectionSnapshot {
371
391
  session: SessionSnapshot;
372
392
  }
373
393
  ```
374
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dredless",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "A fully-featured and from-scratch headless drednot.io client implementation written in pure js.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/certainhuman/dredless#readme",
@@ -152,6 +152,7 @@
152
152
  }
153
153
  },
154
154
  "dependencies": {
155
+ "sharp": "^0.35.4",
155
156
  "ws": "^8.18.0"
156
157
  },
157
158
  "files": [
package/src/browser.d.ts CHANGED
@@ -82,6 +82,9 @@ export {
82
82
  } from "./index.js";
83
83
 
84
84
  export type {
85
+ TipDomain,
86
+ TipList,
87
+ TipOptions,
85
88
  Server,
86
89
  Ship,
87
90
  ShipList,
@@ -1,6 +1,7 @@
1
1
  import {INITIAL_OUTFIT_MESSAGE, JOIN_USER_AGENT, KEEPALIVE_INTERVAL_MS, KEEPALIVE_MESSAGE} from "../constants.js";
2
2
  import {getWebSocket, isNode} from "../runtime.js";
3
3
  import {EventBus} from "../events.js";
4
+ import {TipDomain} from "./domains/tips.js";
4
5
  import {cookieHeader} from "../network/cookies.js";
5
6
  import {fetchServers} from "../network/servers.js";
6
7
  import {Connection} from "../network/connection.js";
@@ -143,6 +144,7 @@ export class DredlessClient extends EventBus {
143
144
  this.debug = new ClientDebugDomain(this);
144
145
  this.player = new PlayerDomain(this);
145
146
  this.inventory = new InventoryDomain(this);
147
+ this.tips = new TipDomain(this);
146
148
  this.management = new ShipManagementDomain(this);
147
149
  this.cpuLoad = null;
148
150
  this.inventoryState = normalizeInventoryEvent({type: "inventory", general_slots: 5});
@@ -1042,6 +1044,7 @@ export class DredlessClient extends EventBus {
1042
1044
 
1043
1045
  #handleSideEvent(event, world) {
1044
1046
  if (!event || typeof event !== "object") return;
1047
+ if (event.type === "tip_list") this.tips.receive(event, world);
1045
1048
  if (event.type === "inventory") {
1046
1049
  this.inventoryState = normalizeInventoryEvent(event);
1047
1050
  this.emit("inventory", this.inventoryState, world);
@@ -835,6 +835,10 @@ export class EntityHandle {
835
835
  use(options = {}, command = {}) {
836
836
  return this.client.player.useEntity(this.id, options, command);
837
837
  }
838
+
839
+ openTips(options = {}) {
840
+ return this.client.tips.open(this, options);
841
+ }
838
842
  }
839
843
 
840
844
  export class MachineHandle extends EntityHandle {
@@ -1228,6 +1232,10 @@ export class HelmHandle extends MachineHandle {
1228
1232
  }
1229
1233
 
1230
1234
  export class DoorHandle extends MachineHandle {
1235
+ setRank(rank, options = {}) {
1236
+ return this.client.tips.setRank(this, rank, options);
1237
+ }
1238
+
1231
1239
  get state() {
1232
1240
  return summaryForEntity(this.client, this.scope, this.id)?.contents?.door || null;
1233
1241
  }
@@ -1242,6 +1250,10 @@ export class DoorHandle extends MachineHandle {
1242
1250
  }
1243
1251
 
1244
1252
  export class SpawnPointHandle extends MachineHandle {
1253
+ setRank(rank, options = {}) {
1254
+ return this.client.tips.setRank(this, rank, options);
1255
+ }
1256
+
1245
1257
  get state() {
1246
1258
  return summaryForEntity(this.client, this.scope, this.id)?.contents?.spawnPoint || null;
1247
1259
  }
@@ -0,0 +1,153 @@
1
+ import {normalizePlayerRank} from "../../protocol/outbound/ship-management.js";
2
+
3
+ function timeoutValue(options) {
4
+ const timeout = options.timeout ?? 5000;
5
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError("timeout must be positive milliseconds");
6
+ return timeout;
7
+ }
8
+
9
+ function waitFor(client, eventType, read, timeout, label, send = null) {
10
+ return new Promise((resolve, reject) => {
11
+ const cleanup = () => {
12
+ clearTimeout(timer);
13
+ client.off(eventType, check);
14
+ client.off("close", closed);
15
+ };
16
+ const check = (...args) => {
17
+ const value = read(...args);
18
+ if (value) { cleanup(); resolve(value); }
19
+ };
20
+ const closed = () => { cleanup(); reject(new Error(`Disconnected while waiting for ${label}`)); };
21
+ const timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for ${label}`)); }, timeout);
22
+ client.on(eventType, check);
23
+ client.on("close", closed);
24
+ if (send) {
25
+ try { send(); } catch (error) { cleanup(); reject(error); return; }
26
+ }
27
+ check();
28
+ });
29
+ }
30
+
31
+ export class TipDomain {
32
+ #busy = false;
33
+ #target = null;
34
+ #current = null;
35
+
36
+ constructor(client) {
37
+ this.client = client;
38
+ client.on("close", () => { this.#current = null; this.#target = null; });
39
+ }
40
+
41
+ get current() {
42
+ return this.#current;
43
+ }
44
+
45
+ receive(event, world) {
46
+ const options = Array.isArray(event.options) ? event.options.map(String) : [];
47
+ const entity = this.#target?.world === world?.id ? this.#target.entity : null;
48
+ const menu = Object.freeze({entity, world: world?.id ?? null, filter: event.filter ?? null,
49
+ x: event.x ?? 0, y: event.y ?? 0, options: Object.freeze(options)});
50
+ this.#current = options.length ? menu : null;
51
+ this.client.emit("tip-list", menu, world);
52
+ }
53
+
54
+ async #exclusive(action) {
55
+ if (this.#busy) throw new Error("A tip menu operation is already in progress");
56
+ this.#busy = true;
57
+ try {
58
+ return await action();
59
+ } catch (error) {
60
+ this.#current = null;
61
+ this.#target = null;
62
+ throw error;
63
+ } finally { this.#busy = false; }
64
+ }
65
+
66
+ async #open(entity, options) {
67
+ if (!this.client.ready || !this.client.connected) throw new Error("Client must be connected and ready to open tips");
68
+ const timeout = timeoutValue(options);
69
+ const handle = typeof entity === "number" ? this.client.currentShip()?.entities.get(entity) : entity;
70
+ if (!handle || handle.client !== this.client || !handle.exists()) throw new Error("Tip target must be an existing entity on this client");
71
+ const world = this.client.worlds.currentWorldId;
72
+ const scopeWorld = handle.scope === "ship" ? this.client.worlds.shipWorld()
73
+ : handle.scope === "current" ? this.client.worlds.worlds.get(world)
74
+ : handle.scope === "overworld" ? this.client.worlds.overworld()
75
+ : this.client.worlds.worlds.get(Number(handle.scope));
76
+ if (scopeWorld?.id !== world) {
77
+ throw new Error("Tip target must be in the current world");
78
+ }
79
+ const position = handle.position;
80
+ if (!position) throw new Error("Tip target has no position");
81
+ this.#current = null;
82
+ this.#target = {entity: handle.id, world, command: {...options.command,
83
+ focus_ent: handle.id, mx: position.x, my: position.y}};
84
+ // Subscribe before sending: attached sockets can deliver a reply synchronously.
85
+ return waitFor(this.client, "tip-list", () => {
86
+ return this.#current?.entity === handle.id && this.#current.world === world ? this.#current : null;
87
+ }, timeout, "tip menu", () => {
88
+ this.client.send({...this.#target.command, act1: false, act2: true, tip_select: null});
89
+ });
90
+ }
91
+
92
+ open(entity, options = {}) {
93
+ return this.#exclusive(() => this.#open(entity, options));
94
+ }
95
+
96
+ close(options = {}) {
97
+ return this.#exclusive(async () => {
98
+ const timeout = timeoutValue(options);
99
+ const menu = this.#current;
100
+ if (!menu) return this.client;
101
+ if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to close tips");
102
+ if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
103
+ await waitFor(this.client, "tip-list", (update) => update?.world === menu.world
104
+ && update.options.length === 0, timeout, "tip menu dismissal", () => {
105
+ // The official client dismisses with a normal left click outside an entity.
106
+ this.client.send({...options.command, focus_ent: null, config_ent: null,
107
+ act1: true, act1_held: true, act2: false, tip_select: null});
108
+ });
109
+ this.#target = null;
110
+ return this.client;
111
+ });
112
+ }
113
+
114
+ select(option, command = {}) {
115
+ if (this.#busy) throw new Error("A tip menu operation is already in progress");
116
+ return this.#select(option, command);
117
+ }
118
+
119
+ #select(option, command = {}) {
120
+ if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to select tips");
121
+ const menu = this.#current;
122
+ if (!menu || menu.entity == null || !this.#target) throw new Error("Open an entity's tip menu before selecting an option");
123
+ if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
124
+ const index = typeof option === "string" ? menu.options.indexOf(option) : option;
125
+ if (!Number.isInteger(index) || index < 0 || index >= menu.options.length) throw new RangeError("Tip option is not in the current menu");
126
+ this.client.send({...this.#target.command, ...command, focus_ent: menu.entity,
127
+ act1: false, act2: false, tip_select: index});
128
+ this.#current = null;
129
+ this.#target = null;
130
+ return this.client;
131
+ }
132
+
133
+ choose(entity, option, options = {}) {
134
+ return this.#exclusive(async () => {
135
+ await this.#open(entity, options);
136
+ return this.#select(option);
137
+ });
138
+ }
139
+
140
+ setRank(handle, rank, options = {}) {
141
+ return this.#exclusive(async () => {
142
+ const value = normalizePlayerRank(rank);
143
+ const timeout = timeoutValue(options);
144
+ if (!handle.exists()) throw new Error("Rank target no longer exists");
145
+ if (handle.rank === value) return this.client;
146
+ const text = {0: "Restrict to Guests", 1: "Restrict to Crew", 3: "Restrict to Captains"}[value];
147
+ await this.#open(handle, options);
148
+ await waitFor(this.client, "model", () => handle.rank === value, timeout, "access rank update",
149
+ () => this.#select(text));
150
+ return this.client;
151
+ });
152
+ }
153
+ }
@@ -1,2 +1,2 @@
1
1
  export {DredlessClient, WrenchMode, TurretMode} from "../index.js";
2
- export type {DredlessClientOptions, ClientSnapshot} from "../index.js";
2
+ export type {DredlessClientOptions, ClientSnapshot, TipDomain, TipList, TipOptions} from "../index.js";
package/src/index.d.ts CHANGED
@@ -26,12 +26,16 @@ export interface ShipList {
26
26
  export interface InviteMetadata {
27
27
  shipId: string | null;
28
28
  name: string | null;
29
- color: string | null;
29
+ /** CSS RGB color sampled from the preview image's top-left pixel, when available. */
30
+ color?: string | null;
30
31
  previewImageUrl: string | null;
31
32
  hostname: string;
32
33
  inviteCode: string;
33
34
  }
34
- export interface InviteFetchOptions { fetchImpl?: typeof fetch; }
35
+ export interface InviteFetchOptions {
36
+ fetchImpl?: typeof fetch;
37
+ includeColor?: boolean;
38
+ }
35
39
  export function fetchInviteMetadata(invite: string | URL, options?: InviteFetchOptions): Promise<InviteMetadata>;
36
40
  export function parseInviteMetadata(html: string): InviteMetadata;
37
41
 
@@ -475,6 +479,7 @@ export class DredlessClient {
475
479
  debug: ClientDebugDomain;
476
480
  player: PlayerDomain;
477
481
  inventory: InventoryDomain;
482
+ tips: TipDomain;
478
483
  management: ShipManagementDomain;
479
484
  readyPromise: Promise<this>;
480
485
 
@@ -508,13 +513,41 @@ export class DredlessClient {
508
513
 
509
514
  entity(entityId: number, scope?: number | string): EntitySnapshot | null;
510
515
 
516
+ on(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
511
517
  on(type: string, callback: (...args: unknown[]) => void): this;
512
518
 
519
+ off(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
513
520
  off(type: string, callback: (...args: unknown[]) => void): this;
514
521
 
522
+ once(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
515
523
  once(type: string, callback: (...args: unknown[]) => void): this;
516
524
  }
517
525
 
526
+ export interface TipList {
527
+ /** Target inferred from tips.open(); null for unsolicited menus. */
528
+ readonly entity: number | null;
529
+ readonly world: number | null;
530
+ readonly filter: number | null;
531
+ readonly x: number;
532
+ readonly y: number;
533
+ readonly options: readonly string[];
534
+ }
535
+
536
+ export interface TipOptions {
537
+ /** Milliseconds to wait per server response; default 5000. */
538
+ timeout?: number;
539
+ command?: Command;
540
+ }
541
+
542
+ export interface TipDomain {
543
+ readonly current: TipList | null;
544
+ open(entity: number | EntityHandle, options?: TipOptions): Promise<TipList>;
545
+ /** Dismiss the menu and wait for the server's empty tip list. */
546
+ close(options?: TipOptions): Promise<DredlessClient>;
547
+ select(option: number | string, command?: Command): DredlessClient;
548
+ choose(entity: number | EntityHandle, option: number | string, options?: TipOptions): Promise<DredlessClient>;
549
+ }
550
+
518
551
  export interface ClientNetDomain {
519
552
  connected: boolean;
520
553
  ready: boolean;
@@ -948,6 +981,7 @@ export interface EntityHandle {
948
981
  asExpandoBox(): ExpandoBoxHandle | null;
949
982
 
950
983
  use(options?: { invSlot?: number; hold?: boolean }, command?: Command): DredlessClient;
984
+ openTips(options?: TipOptions): Promise<TipList>;
951
985
  }
952
986
 
953
987
  export interface MachineHandle extends EntityHandle {
@@ -1133,11 +1167,13 @@ export interface DoorHandle extends EntityHandle {
1133
1167
  state: DoorSummary | null;
1134
1168
  rank: number | undefined;
1135
1169
  open: boolean | undefined;
1170
+ setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
1136
1171
  }
1137
1172
 
1138
1173
  export interface SpawnPointHandle extends MachineHandle {
1139
1174
  state: SpawnPointSummary | null;
1140
1175
  rank: number | undefined;
1176
+ setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
1141
1177
  }
1142
1178
 
1143
1179
  export interface ShieldProjectorHandle extends MachineHandle {
@@ -1,11 +1,15 @@
1
1
  export interface InviteMetadata {
2
2
  shipId: string | null;
3
3
  name: string | null;
4
- color: string | null;
4
+ /** CSS RGB color sampled from the preview image's top-left pixel, when available. */
5
+ color?: string | null;
5
6
  previewImageUrl: string | null;
6
7
  hostname: string;
7
8
  inviteCode: string;
8
9
  }
9
- export interface InviteFetchOptions { fetchImpl?: typeof fetch; }
10
+ export interface InviteFetchOptions {
11
+ fetchImpl?: typeof fetch;
12
+ includeColor?: boolean;
13
+ }
10
14
  export function fetchInviteMetadata(invite: string | URL, options?: InviteFetchOptions): Promise<InviteMetadata>;
11
15
  export function parseInviteMetadata(html: string): InviteMetadata;
@@ -1,10 +1,48 @@
1
- import {getFetch} from "../runtime.js";
1
+ import {getFetch, isBrowser} from "../runtime.js";
2
2
 
3
- export async function fetchInviteMetadata(invite, {fetchImpl = null} = {}) {
3
+ export async function fetchInviteMetadata(invite, {fetchImpl = null, includeColor = false} = {}) {
4
4
  const url = normalizeInviteUrl(invite);
5
- const response = await (fetchImpl || getFetch())(url.href, {headers: {accept: "text/html"}});
5
+ const fetcher = fetchImpl || getFetch();
6
+ const response = await fetcher(url.href, {headers: {accept: "text/html"}});
6
7
  if (!response.ok) throw new Error(`Invite metadata request failed: ${response.status} ${response.statusText}`);
7
- return parseInviteMetadata(await response.text());
8
+ const metadata = parseInviteMetadata(await response.text());
9
+ if (!includeColor || !metadata.previewImageUrl) return metadata;
10
+ return Object.freeze({...metadata, color: await fetchTopLeftColor(metadata.previewImageUrl, fetcher)});
11
+ }
12
+
13
+ async function fetchTopLeftColor(imageUrl, fetcher) {
14
+ try {
15
+ let response = await fetcher(imageUrl, {headers: {range: "bytes=0-1023"}});
16
+ if (!response.ok) return null;
17
+ const buffer = await response.arrayBuffer();
18
+ try {
19
+ return await decodeImageColor(buffer);
20
+ } catch (error) {
21
+ if (response.status !== 206) throw error;
22
+ response = await fetcher(imageUrl);
23
+ if (!response.ok) return null;
24
+ return await decodeImageColor(await response.arrayBuffer());
25
+ }
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ async function decodeImageColor(buffer) {
32
+ if (isBrowser()) {
33
+ const bitmap = await createImageBitmap(new Blob([buffer]));
34
+ const canvas = typeof OffscreenCanvas === "function" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
35
+ canvas.width = canvas.height = 1;
36
+ const context = canvas.getContext("2d");
37
+ context.drawImage(bitmap, 0, 0, 1, 1);
38
+ const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
39
+ bitmap.close();
40
+ return `rgb(${red},${green},${blue})`;
41
+ }
42
+ const sharpModule = await import("sharp");
43
+ const sharp = sharpModule.default || sharpModule;
44
+ const {data} = await sharp(buffer).extract({left: 0, top: 0, width: 1, height: 1}).raw().toBuffer({resolveWithObject: true});
45
+ return `rgb(${data[0]},${data[1]},${data[2]})`;
8
46
  }
9
47
 
10
48
  export function parseInviteMetadata(html) {
@@ -15,7 +53,6 @@ export function parseInviteMetadata(html) {
15
53
  return Object.freeze({
16
54
  shipId: parsedTitle.shipId,
17
55
  name: parsedTitle.name,
18
- color: metaContent(html, "theme-color") || metaContent(html, "color") || null,
19
56
  previewImageUrl: preview ? new URL(preview, url).href : null,
20
57
  hostname: url.hostname,
21
58
  inviteCode: url.pathname.split("/").filter(Boolean).at(-1)
@@ -29,7 +66,7 @@ function normalizeInviteUrl(invite) {
29
66
  }
30
67
 
31
68
  function parseInviteTitle(title) {
32
- const match = title.match(/^Invite:\s?(.*?)\s*\{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
69
+ const match = title.match(/^Invite:\s?(.+?) \{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
33
70
  return match ? {name: decodeHtml(match[1]), shipId: decodeHtml(match[2])} : {name: null, shipId: null};
34
71
  }
35
72