@gryt/core 0.27.0 → 0.29.0

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.
@@ -20,15 +20,22 @@ export interface ImportDeps {
20
20
  download: (url: string) => Promise<Omit<ImportFile, "name"> | {
21
21
  retryAfterMs: number;
22
22
  } | null>;
23
+ /** `retryAfterMs` when the server answers 429, `tooLarge` when it refuses the file's size. */
23
24
  upload: (file: ImportFile) => Promise<{
24
25
  id: string;
25
26
  key: SealedAttachmentKey;
27
+ } | {
28
+ retryAfterMs: number;
29
+ } | {
30
+ tooLarge: true;
26
31
  }>;
27
32
  store: ImportFileStore;
28
33
  sleep: (ms: number) => Promise<void>;
29
34
  newId: () => string;
30
35
  /** Downloads and uploads running together. A phone holding each file in memory wants fewer. */
31
36
  transfersAtOnce?: number;
37
+ /** Send a big import's text as one sealed upload. Only for apps that read `import_bundle`. */
38
+ bundles?: boolean;
32
39
  }
33
40
  export interface ImportProgress {
34
41
  messages: {
@@ -44,6 +51,8 @@ export interface ImportProgress {
44
51
  total: number;
45
52
  };
46
53
  missing: number;
54
+ /** Files this server won't take, each left out with a note on its message. */
55
+ tooLarge: number;
47
56
  bytes: number;
48
57
  startedAt: number;
49
58
  }
@@ -1,9 +1,13 @@
1
- import { importBatches } from "../mls/content.js";
1
+ import { encodeImportBundle, importBatches } from "../mls/content.js";
2
2
  const TRANSFERS_AT_ONCE = 16;
3
3
  /** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
4
4
  const SEND_GAP_MS = 2_000;
5
5
  const FILE_BATCH = 20;
6
+ /* A batch holds up to 200, and one send is the point where a bundle starts saving time. */
7
+ const BUNDLE_ABOVE = 200;
6
8
  const DOWNLOAD_RETRIES = 5;
9
+ /* A server allows 30 uploads a minute with a ban after, so a big import waits many times over. */
10
+ const UPLOAD_RETRIES = 200;
7
11
  const MAX_RATE_LIMIT_WAIT_MS = 60_000;
8
12
  const DEFAULT_RATE_LIMIT_WAIT_MS = 5_000;
9
13
  /** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
@@ -29,6 +33,7 @@ export async function runDiscordImport(messages, deps, control, report) {
29
33
  downloaded: { done: 0, total: urls.length },
30
34
  sent: { done: 0, total: urls.length },
31
35
  missing: 0,
36
+ tooLarge: 0,
32
37
  bytes: 0,
33
38
  startedAt: Date.now(),
34
39
  };
@@ -38,6 +43,32 @@ export async function runDiscordImport(messages, deps, control, report) {
38
43
  while (control.paused() && !control.stopped())
39
44
  await deps.sleep(500);
40
45
  };
46
+ let uploadClearAt = 0;
47
+ const uploadFile = async (file) => {
48
+ for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
49
+ const wait = uploadClearAt - Date.now();
50
+ if (wait > 0)
51
+ await deps.sleep(wait);
52
+ const got = await deps.upload(file);
53
+ if (!("retryAfterMs" in got))
54
+ return got;
55
+ uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
56
+ }
57
+ if (control.stopped())
58
+ return null;
59
+ throw new Error("The server kept refusing uploads.");
60
+ };
61
+ /* False when the bundle can't go up, so the batches carry it. */
62
+ const sendBundle = async (entries) => {
63
+ await hold();
64
+ const up = await uploadFile({ name: "discord-import.json", type: "application/json", bytes: encodeImportBundle(entries) });
65
+ if (!up || "tooLarge" in up)
66
+ return false;
67
+ await deps.send({ type: "import_bundle", id: deps.newId(), source: "discord", attachments: { [up.id]: up.key } });
68
+ p.messages.done += entries.length;
69
+ tick();
70
+ return true;
71
+ };
41
72
  const sendAll = async (entries) => {
42
73
  for (const batch of importBatches(entries, deps.newId)) {
43
74
  await hold();
@@ -49,7 +80,9 @@ export async function runDiscordImport(messages, deps, control, report) {
49
80
  await deps.sleep(SEND_GAP_MS);
50
81
  }
51
82
  };
52
- await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
83
+ const textOnly = messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text }));
84
+ if (!(deps.bundles && textOnly.length > BUNDLE_ABOVE && !control.stopped() && (await sendBundle(textOnly))))
85
+ await sendAll(textOnly);
53
86
  const missing = new Set();
54
87
  let clearAt = 0;
55
88
  const downloadFile = async (url) => {
@@ -92,11 +125,20 @@ export async function runDiscordImport(messages, deps, control, report) {
92
125
  tick();
93
126
  });
94
127
  const uploaded = new Map();
128
+ const tooLarge = new Set();
95
129
  await eachAtOnce(async (url) => {
96
130
  const stored = missing.has(url) ? null : await deps.store.take(url);
97
131
  if (stored) {
98
- uploaded.set(url, await deps.upload({ name: nameOf(url), ...stored }));
99
- await deps.store.drop(url);
132
+ const up = await uploadFile({ name: nameOf(url), ...stored });
133
+ if (up && "tooLarge" in up) {
134
+ tooLarge.add(url);
135
+ p.tooLarge = tooLarge.size;
136
+ }
137
+ else if (up) {
138
+ uploaded.set(url, up);
139
+ }
140
+ if (up)
141
+ await deps.store.drop(url);
100
142
  }
101
143
  p.sent.done++;
102
144
  tick();
@@ -111,9 +153,11 @@ export async function runDiscordImport(messages, deps, control, report) {
111
153
  if (up)
112
154
  attachments[up.id] = up.key;
113
155
  }
114
- const lost = m.attachments.length - Object.keys(attachments).length;
115
- const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
116
- const text = [m.text, note].filter(Boolean).join("\n");
156
+ const big = m.attachments.filter((u) => tooLarge.has(u)).length;
157
+ const lost = m.attachments.length - Object.keys(attachments).length - big;
158
+ const lostNote = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
159
+ const bigNote = big ? `${big === 1 ? "A file" : `${big} files`} from Discord ${big === 1 ? "was" : "were"} too big for this server.` : "";
160
+ const text = [m.text, lostNote, bigNote].filter(Boolean).join("\n");
117
161
  pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
118
162
  if (pending.length >= FILE_BATCH) {
119
163
  await sendAll(pending);
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
13
13
  export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { reencryptedId, reencryptMine, type ReencryptCandidate, type ReencryptDeps } from "./mls/reencrypt.js";
16
- export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
16
+ export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
17
17
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
18
18
  export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
19
19
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
15
15
  export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
16
16
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
17
17
  export { reencryptedId, reencryptMine } from "./mls/reencrypt.js";
18
- export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
+ export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
19
19
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
20
20
  export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
21
21
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
@@ -30,6 +30,13 @@ export type MlsDmContent = {
30
30
  id: string;
31
31
  source: "discord";
32
32
  entries: MlsImportEntry[];
33
+ } | {
34
+ /** An import too big for messages: its entries sit in one sealed upload, read with `readImportBundle`. */
35
+ type: "import_bundle";
36
+ id: string;
37
+ source: "discord";
38
+ /** Upload id to its key. Exactly one. */
39
+ attachments: Record<string, SealedAttachmentKey>;
33
40
  } | {
34
41
  type: "history";
35
42
  /** For the batch itself, so a repeat is the same message. */
@@ -82,6 +89,9 @@ export declare function acceptImport(content: Extract<MlsDmContent, {
82
89
  senderId: string;
83
90
  at: number;
84
91
  }): MlsHistoryEntry[];
92
+ export declare function encodeImportBundle(entries: MlsImportEntry[]): Uint8Array;
93
+ /** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
94
+ export declare function readImportBundle(bytes: Uint8Array): MlsImportEntry[] | null;
85
95
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
86
96
  export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
87
97
  /**
@@ -84,7 +84,10 @@ function readAttachments(v) {
84
84
  /** The same bytes the phone writes: `v` first, then the content's fields in this order. */
85
85
  export function encodeMlsDmContent(content) {
86
86
  let body;
87
- if (content.type === "history") {
87
+ if (content.type === "import_bundle") {
88
+ body = { type: content.type, id: content.id, source: content.source, attachments: content.attachments };
89
+ }
90
+ else if (content.type === "history") {
88
91
  body = { type: content.type, id: content.id, entries: content.entries.map(historyBody) };
89
92
  }
90
93
  else if (content.type === "import") {
@@ -213,6 +216,17 @@ function readImportEntry(v) {
213
216
  }
214
217
  return out;
215
218
  }
219
+ export function encodeImportBundle(entries) {
220
+ return new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, entries: entries.map(importBody) }));
221
+ }
222
+ /** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
223
+ export function readImportBundle(bytes) {
224
+ const raw = parse(bytes);
225
+ if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !Array.isArray(raw.entries))
226
+ return null;
227
+ const entries = raw.entries.map(readImportEntry);
228
+ return entries.every((e) => e !== null) ? entries : null;
229
+ }
216
230
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
217
231
  export function decodeMlsDmContent(bytes) {
218
232
  return fromRaw(parse(bytes));
@@ -225,7 +239,7 @@ export function readMlsDmContent(bytes) {
225
239
  const raw = parse(bytes);
226
240
  return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
227
241
  }
228
- const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import"]);
242
+ const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import", "import_bundle"]);
229
243
  const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
230
244
  /** A later version, or a type this one doesn't know that names its message the way ours do. */
231
245
  function isNewer(raw) {
@@ -259,6 +273,10 @@ function fromRaw(raw) {
259
273
  const entries = raw.entries.map(readImportEntry);
260
274
  return entries.every((e) => e !== null) ? { type, id, source: raw.source, entries } : null;
261
275
  }
276
+ if (type === "import_bundle") {
277
+ const attachments = raw.source === "discord" ? readAttachments(raw.attachments) : null;
278
+ return attachments && Object.keys(attachments).length === 1 ? { type, id, source: "discord", attachments } : null;
279
+ }
262
280
  if (type === "message" && isText(text)) {
263
281
  const out = { type, id, text };
264
282
  if (raw.replyTo !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {