@gryt/core 0.28.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.
@@ -34,6 +34,8 @@ export interface ImportDeps {
34
34
  newId: () => string;
35
35
  /** Downloads and uploads running together. A phone holding each file in memory wants fewer. */
36
36
  transfersAtOnce?: number;
37
+ /** Send a big import's text as one sealed upload. Only for apps that read `import_bundle`. */
38
+ bundles?: boolean;
37
39
  }
38
40
  export interface ImportProgress {
39
41
  messages: {
@@ -1,8 +1,10 @@
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;
7
9
  /* A server allows 30 uploads a minute with a ban after, so a big import waits many times over. */
8
10
  const UPLOAD_RETRIES = 200;
@@ -41,6 +43,32 @@ export async function runDiscordImport(messages, deps, control, report) {
41
43
  while (control.paused() && !control.stopped())
42
44
  await deps.sleep(500);
43
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
+ };
44
72
  const sendAll = async (entries) => {
45
73
  for (const batch of importBatches(entries, deps.newId)) {
46
74
  await hold();
@@ -52,7 +80,9 @@ export async function runDiscordImport(messages, deps, control, report) {
52
80
  await deps.sleep(SEND_GAP_MS);
53
81
  }
54
82
  };
55
- 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);
56
86
  const missing = new Set();
57
87
  let clearAt = 0;
58
88
  const downloadFile = async (url) => {
@@ -96,21 +126,6 @@ export async function runDiscordImport(messages, deps, control, report) {
96
126
  });
97
127
  const uploaded = new Map();
98
128
  const tooLarge = new Set();
99
- let uploadClearAt = 0;
100
- const uploadFile = async (file) => {
101
- for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
102
- const wait = uploadClearAt - Date.now();
103
- if (wait > 0)
104
- await deps.sleep(wait);
105
- const got = await deps.upload(file);
106
- if (!("retryAfterMs" in got))
107
- return got;
108
- uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
109
- }
110
- if (control.stopped())
111
- return null;
112
- throw new Error("The server kept refusing uploads.");
113
- };
114
129
  await eachAtOnce(async (url) => {
115
130
  const stored = missing.has(url) ? null : await deps.store.take(url);
116
131
  if (stored) {
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.28.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": {