@makefully/adaptfully 5.0.1 → 5.0.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 5.0.3 — 2026-10-05
6
+
7
+ ### Changed
8
+
9
+ - **Web packager** writes `game-config.js` (and injects the script) for **web** and **pwa** as well as **uwp**, so `window.gameConfig.version` / platform are stamped on every web-family route — matching electron and capacitor. HTTP analytics can then populate batch `appVersion` without a per-game `analyticsAppVersion` override.
10
+
11
+ ## 5.0.2 — 2026-10-05
12
+
13
+ ### Fixed
14
+
15
+ - Wrapfully chore downloads stream to a temp `.zip` on disk and extract with **yauzl** (lazy file streams) instead of buffering the whole Electron/Steam artifact in RAM via `axios` `arraybuffer` + `unzip-stream` MatcherStream (Node heap OOM despite free system RAM).
16
+ - Encrypted result envelopes decrypt with streamed AES-GCM (`aesDecryptFile`) to a plaintext zip file before extract.
17
+
5
18
  ## 5.0.1 — 2026-09-17
6
19
 
7
20
  ### Changed
package/README.md CHANGED
@@ -178,7 +178,7 @@ Each platform entry may set a **`packager`** (`web`, `electron`, or `capacitor`)
178
178
 
179
179
  | Packager | Prebuild adds |
180
180
  |----------|---------------|
181
- | `web` | `game-config.js` for **`uwp`** platform prebuilds |
181
+ | `web` | `game-config.js` for **web / uwp / pwa** platform prebuilds (version + platform for analytics) |
182
182
  | `electron` | `main.js` (Electron shell); `preload.js` when **`steam-auth`** is registered |
183
183
  | `capacitor` | `game-config.js`, Capacitor CSP/viewport; `social-login-config.js` when **`social-auth`** is registered |
184
184
 
@@ -2,14 +2,14 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import {Readable, PassThrough} from 'node:stream';
5
+ import {PassThrough} from 'node:stream';
6
6
  import {pipeline} from 'node:stream/promises';
7
7
  import archiver from 'archiver';
8
- import unzipper from 'unzip-stream';
9
8
  import {
10
9
  WRAPFULLY_DEFAULT_KID,
11
10
  WRAPFULLY_DEFAULT_PUBLIC_KEY,
12
11
  } from './wrapfullyDefaultPublicKey.js';
12
+ import {extractZipFile, zipHasEntries} from './unzip-file.js';
13
13
 
14
14
  const
15
15
  ENVELOPE_DATA = 'wrapfully.enc',
@@ -40,6 +40,38 @@ function aesDecrypt (key, blob) {
40
40
  return Buffer.concat([decipher.update(data), decipher.final()]);
41
41
  }
42
42
 
43
+ /**
44
+ * Stream-decrypt an AES-256-GCM blob (iv||tag||ciphertext) from disk to disk.
45
+ * @param {Buffer} key
46
+ * @param {string} inPath
47
+ * @param {string} outPath
48
+ */
49
+ export async function aesDecryptFile (key, inPath, outPath) {
50
+ const fh = await fs.promises.open(inPath, 'r');
51
+ const header = Buffer.alloc(28);
52
+ let bytesRead = 0;
53
+
54
+ try {
55
+ ({bytesRead} = await fh.read(header, 0, 28, 0));
56
+ } finally {
57
+ await fh.close();
58
+ }
59
+ if (bytesRead < 28) {
60
+ throw new Error('Encrypted payload too short for AES-GCM header');
61
+ }
62
+
63
+ const iv = header.subarray(0, 12);
64
+ const tag = header.subarray(12, 28);
65
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
66
+
67
+ decipher.setAuthTag(tag);
68
+ await pipeline(
69
+ fs.createReadStream(inPath, {start: 28}),
70
+ decipher,
71
+ fs.createWriteStream(outPath),
72
+ );
73
+ }
74
+
43
75
  function wrapKey (aesKey, publicKeyPem) {
44
76
  return crypto.publicEncrypt(
45
77
  {
@@ -152,32 +184,71 @@ export async function createEnvelopeFromInnerZip (innerZip, {publicKeyPem, kid,
152
184
  }
153
185
 
154
186
  /**
155
- * Decrypt a completed Yap result envelope into a plaintext zip Buffer.
187
+ * Decrypt a completed Yap result envelope zip on disk into a plaintext zip file.
188
+ * Avoids holding the Electron/Steam artifact in RAM.
189
+ * @param {string} envelopeZipPath
190
+ * @param {string} resultPrivPem
191
+ * @param {string} outZipPath
192
+ * @returns {Promise<'envelope'|'passthrough'>} passthrough when file is already a plain zip
156
193
  */
157
- export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
194
+ export async function decryptResultEnvelopeToFile (envelopeZipPath, resultPrivPem, outZipPath) {
158
195
  if (!resultPrivPem) {
159
196
  throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
160
197
  }
161
198
 
199
+ const isEnvelope = await zipHasEntries(envelopeZipPath, [RESULT_DATA, RESULT_KEY]);
200
+
201
+ if (!isEnvelope) {
202
+ // Not an envelope — copy as-is (streamed); never extract the whole artifact.
203
+ await pipeline(
204
+ fs.createReadStream(envelopeZipPath),
205
+ fs.createWriteStream(outZipPath),
206
+ );
207
+
208
+ return 'passthrough';
209
+ }
210
+
162
211
  const tmp = path.join(os.tmpdir(), `adaptfully-result-${Date.now().toString(16)}`);
163
212
 
164
213
  fs.mkdirSync(tmp, {recursive: true});
165
214
  try {
166
- await pipeline(Readable.from(resultZipBuf), unzipper.Extract({path: tmp}));
167
- if (!fs.existsSync(path.join(tmp, RESULT_DATA))) {
168
- // Not an envelope — return as-is
169
- return resultZipBuf;
170
- }
171
- const ciphertext = fs.readFileSync(path.join(tmp, RESULT_DATA));
215
+ // Envelope zips are small (AES blob + wrapped key).
216
+ await extractZipFile(envelopeZipPath, tmp);
172
217
  const wrapped = fs.readFileSync(path.join(tmp, RESULT_KEY));
173
218
  const aesKey = unwrapKey(wrapped, resultPrivPem);
174
219
 
175
- return aesDecrypt(aesKey, ciphertext);
220
+ await aesDecryptFile(aesKey, path.join(tmp, RESULT_DATA), outZipPath);
221
+
222
+ return 'envelope';
176
223
  } finally {
177
224
  fs.rmSync(tmp, {recursive: true, force: true});
178
225
  }
179
226
  }
180
227
 
228
+ /**
229
+ * Decrypt a completed Yap result envelope into a plaintext zip Buffer.
230
+ * Prefer {@link decryptResultEnvelopeToFile} for large Electron artifacts.
231
+ */
232
+ export async function decryptResultEnvelope (resultZipBuf, resultPrivPem) {
233
+ if (!resultPrivPem) {
234
+ throw new Error('Missing resultPriv for encrypted Wrapfully chore download.');
235
+ }
236
+
237
+ const stamp = Date.now().toString(16);
238
+ const envelopePath = path.join(os.tmpdir(), `adaptfully-env-${stamp}.zip`);
239
+ const outPath = path.join(os.tmpdir(), `adaptfully-plain-${stamp}.zip`);
240
+
241
+ try {
242
+ fs.writeFileSync(envelopePath, resultZipBuf);
243
+ await decryptResultEnvelopeToFile(envelopePath, resultPrivPem, outPath);
244
+
245
+ return fs.readFileSync(outPath);
246
+ } finally {
247
+ fs.rmSync(envelopePath, {force: true});
248
+ fs.rmSync(outPath, {force: true});
249
+ }
250
+ }
251
+
181
252
  export function isResultEnvelopeBuffer (buf) {
182
253
  // Weak check — PK zip; decryptResultEnvelope falls back if members missing
183
254
  return Buffer.isBuffer(buf) && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b;
@@ -1,19 +1,19 @@
1
1
  import axios from 'axios';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
- import {Readable} from 'node:stream';
5
5
  import {pipeline} from 'node:stream/promises';
6
- import unzipper from 'unzip-stream';
7
6
  import {createDeployArchive, createReleaseArchive, createSourceArchive} from './archive.js';
8
7
  import {clearStaleBuildExtract} from './artifacts.js';
9
8
  import {listHtmlFilesRecursive} from './fs-utils.js';
10
9
  import {printBuildReport} from './report.js';
11
10
  import {
12
11
  createEnvelopeFromInnerZip,
13
- decryptResultEnvelope,
12
+ decryptResultEnvelopeToFile,
14
13
  generateResultKeyPair,
15
14
  resolveEncryptPublicKey,
16
15
  } from './choreCrypto.js';
16
+ import {extractZipFile, fileLooksLikeZip} from './unzip-file.js';
17
17
 
18
18
  const POLL_MS = 5000;
19
19
 
@@ -47,22 +47,28 @@ export function buildWrapfullyJobConfig (stage, family, gameId, platformKey, dep
47
47
  return job;
48
48
  }
49
49
 
50
- async function streamToBuffer (stream) {
51
- const chunks = [];
50
+ async function sleep (ms) {
51
+ await new Promise((resolve) => setTimeout(resolve, ms));
52
+ }
52
53
 
53
- for await (const chunk of stream) {
54
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
55
- }
54
+ /**
55
+ * Stream an axios response body to a file (does not buffer the whole body in RAM).
56
+ * @param {import('axios').AxiosResponse} response
57
+ * @param {string} outPath
58
+ */
59
+ async function streamAxiosResponseToFile (response, outPath) {
60
+ const body = response.data;
56
61
 
57
- return Buffer.concat(chunks);
58
- }
62
+ if (!body || typeof body.pipe !== 'function') {
63
+ throw new Error('Expected a streaming HTTP response body');
64
+ }
59
65
 
60
- async function sleep (ms) {
61
- await new Promise((resolve) => setTimeout(resolve, ms));
66
+ await pipeline(body, fs.createWriteStream(outPath));
62
67
  }
63
68
 
64
69
  /**
65
- * Submit zip to Showfully Yap, poll until complete, return result zip Buffer.
70
+ * Submit zip to Showfully Yap, poll until complete, stream result zip to disk.
71
+ * @returns {Promise<string>} absolute path to the downloaded zip (caller deletes)
66
72
  */
67
73
  export async function submitWrapfullyChore ({
68
74
  server,
@@ -159,22 +165,49 @@ export async function submitWrapfullyChore ({
159
165
 
160
166
  log(`adaptfully: downloading chore ${choreId}`);
161
167
  const downloadUrl = `${base}yap/wrapfully/${encodeURIComponent(choreId)}`;
162
- const {data, headers: resHeaders} = await axios.get(downloadUrl, {
163
- headers: {Authorization: `Bearer ${accessToken}`},
164
- responseType: 'arraybuffer',
165
- maxContentLength: Infinity,
166
- validateStatus: () => true,
167
- });
168
+ const downloadPath = path.join(
169
+ os.tmpdir(),
170
+ `adaptfully-chore-${choreId}-${Date.now().toString(16)}.zip`,
171
+ );
168
172
 
169
- const ctype = resHeaders['content-type'] || '';
173
+ try {
174
+ const response = await axios.get(downloadUrl, {
175
+ headers: {Authorization: `Bearer ${accessToken}`},
176
+ responseType: 'stream',
177
+ maxContentLength: Infinity,
178
+ maxBodyLength: Infinity,
179
+ validateStatus: () => true,
180
+ });
181
+ const ctype = response.headers['content-type'] || '';
182
+
183
+ if (response.status >= 400 || ctype.includes('application/json')) {
184
+ const chunks = [];
185
+
186
+ for await (const chunk of response.data) {
187
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
188
+ }
189
+ const text = Buffer.concat(chunks).toString('utf8');
190
+ let message = 'Chore download failed';
191
+
192
+ try {
193
+ const json = JSON.parse(text);
170
194
 
171
- if (ctype.includes('application/json')) {
172
- const json = JSON.parse(Buffer.from(data).toString('utf8'));
195
+ message = json.errors?.[0] || message;
196
+ } catch {
197
+ if (text.trim()) {
198
+ message = text.slice(0, 500);
199
+ }
200
+ }
201
+ throw new Error(message);
202
+ }
173
203
 
174
- throw new Error(json.errors?.[0] || 'Chore download returned JSON');
204
+ await streamAxiosResponseToFile(response, downloadPath);
205
+ } catch (err) {
206
+ fs.rmSync(downloadPath, {force: true});
207
+ throw err;
175
208
  }
176
209
 
177
- return Buffer.from(data);
210
+ return downloadPath;
178
211
  }
179
212
 
180
213
  /**
@@ -252,29 +285,17 @@ export async function send (
252
285
  log(`adaptfully: sending ${htmlFiles.length} HTML file(s) from ${path.resolve(deployFolder)}`);
253
286
  }
254
287
 
255
- // Append wrapfully.json routing into the archive by rebuilding into a buffer.
288
+ // Append wrapfully.json by extracting the upload archive to disk, then rezipping.
256
289
  const job = buildWrapfullyJobConfig(stage, family, gameId, platformKey, options.deploymentKey);
257
290
  const innerChunks = [];
258
-
259
- // Re-pack: read archiveStream into zip, add wrapfully.json
260
- // Archiver streams aren't easily mutable — collect then use yazl-free approach:
261
- // pipe archive to buffer, unzip to tmp, add file, rezip is heavy.
262
- // Simpler: createSourceArchive already finalized as stream — append via second archiver
263
- // that copies entries is complex. Instead append wrapfully.json by concatenating
264
- // a sidecar zip is wrong. Best: write wrapfully.json into deploy folder meta before
265
- // archive... but that mutates disk.
266
- //
267
- // Practical approach matching Dutifully: build archive, buffer it, then use a new
268
- // archiver that includes the buffer as... no that's nested.
269
- //
270
- // Use jszip-like: buffer the created archive by consuming stream, then use
271
- // adm-zip equivalent — we have unzipper. Extract to tmp, write wrapfully.json, rezip.
272
-
273
- const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${Date.now().toString(16)}`);
291
+ const stamp = Date.now().toString(16);
292
+ const tmpRoot = path.join(outputRoot, `.adaptfully-yap-${stamp}`);
293
+ const uploadZipPath = path.join(outputRoot, `.adaptfully-upload-${stamp}.zip`);
274
294
 
275
295
  fs.mkdirSync(tmpRoot, {recursive: true});
276
296
  try {
277
- await pipeline(archiveStream, unzipper.Extract({path: tmpRoot, concurrency: 1}));
297
+ await pipeline(archiveStream, fs.createWriteStream(uploadZipPath));
298
+ await extractZipFile(uploadZipPath, tmpRoot);
278
299
  fs.writeFileSync(path.join(tmpRoot, 'wrapfully.json'), JSON.stringify(job, null, 2));
279
300
 
280
301
  const {default: archiver} = await import('archiver');
@@ -296,6 +317,7 @@ export async function send (
296
317
  await done;
297
318
  } finally {
298
319
  fs.rmSync(tmpRoot, {recursive: true, force: true});
320
+ fs.rmSync(uploadZipPath, {force: true});
299
321
  }
300
322
 
301
323
  let uploadBuffer = Buffer.concat(innerChunks);
@@ -318,39 +340,58 @@ export async function send (
318
340
  log(`adaptfully: encrypting chore with kid "${kid}"`);
319
341
  }
320
342
 
321
- let resultBuffer = await submitWrapfullyChore({
343
+ const downloadPath = await submitWrapfullyChore({
322
344
  server,
323
345
  accessToken,
324
346
  zipBuffer: uploadBuffer,
325
347
  log,
326
348
  });
349
+ const plainZipPath = path.join(
350
+ os.tmpdir(),
351
+ `adaptfully-plain-${path.basename(downloadPath)}`,
352
+ );
353
+ let resultZipPath = downloadPath;
327
354
 
328
- if (resultPriv) {
329
- try {
330
- resultBuffer = await decryptResultEnvelope(resultBuffer, resultPriv);
331
- } catch (err) {
332
- throw new Error(`Failed to decrypt Wrapfully result envelope: ${err.message || err}`);
355
+ try {
356
+ if (resultPriv) {
357
+ try {
358
+ await decryptResultEnvelopeToFile(downloadPath, resultPriv, plainZipPath);
359
+ resultZipPath = plainZipPath;
360
+ } catch (err) {
361
+ throw new Error(
362
+ `Failed to decrypt Wrapfully result envelope: ${err.message || err}`
363
+ );
364
+ }
333
365
  }
334
- }
335
-
336
- if (!Buffer.isBuffer(resultBuffer) || resultBuffer.length < 4
337
- || resultBuffer[0] !== 0x50 || resultBuffer[1] !== 0x4b) {
338
- throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
339
- }
340
366
 
341
- const destination = mode === 'extract'
342
- ? unzipper.Extract({path: `${extractRoot}/`, concurrency: 1})
343
- : fs.createWriteStream(`${extractRoot}/${pkg.name}-${pkg.version}-${stage}-${family}.zip`);
367
+ if (!fileLooksLikeZip(resultZipPath)) {
368
+ throw new Error('Chore download is not a valid zip (truncated or corrupt response)');
369
+ }
344
370
 
345
- try {
346
- await pipeline(Readable.from(resultBuffer), destination);
347
- } catch (err) {
348
- throw new Error(
349
- `Failed to unpack chore result (${resultBuffer.length} bytes): ${err.message || err}`
350
- );
351
- }
371
+ if (mode === 'extract') {
372
+ try {
373
+ await extractZipFile(resultZipPath, extractRoot);
374
+ } catch (err) {
375
+ const size = fs.statSync(resultZipPath).size;
352
376
 
353
- if (mode === 'extract') {
354
- printBuildReport(`${stage}-${family}`, pkg, { extractRoot });
377
+ throw new Error(
378
+ `Failed to unpack chore result (${size} bytes): ${err.message || err}`
379
+ );
380
+ }
381
+ printBuildReport(`${stage}-${family}`, pkg, {extractRoot});
382
+ } else {
383
+ const destZip = path.join(
384
+ extractRoot,
385
+ `${pkg.name}-${pkg.version}-${stage}-${family}.zip`,
386
+ );
387
+
388
+ await pipeline(
389
+ fs.createReadStream(resultZipPath),
390
+ fs.createWriteStream(destZip),
391
+ );
392
+ }
393
+ } finally {
394
+ fs.rmSync(downloadPath, {force: true});
395
+ fs.rmSync(plainZipPath, {force: true});
355
396
  }
356
397
  }
@@ -8,6 +8,8 @@ export class WebPackager extends Packager {
8
8
  static defaultPlatforms = ['web', 'uwp', 'pwa'];
9
9
 
10
10
  needsGameConfig() {
11
- return this.platformKey === 'uwp';
11
+ // Stamp version/platform for all web-family routes (web, uwp, pwa) so
12
+ // analytics resolveAppVersion() matches electron/capacitor builds.
13
+ return true;
12
14
  }
13
15
  }
@@ -0,0 +1,194 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {pipeline} from 'node:stream/promises';
4
+ import yauzl from 'yauzl';
5
+
6
+ /**
7
+ * Resolve entry path under destDir; reject zip-slip escapes.
8
+ * @param {string} destDir
9
+ * @param {string} fileName
10
+ */
11
+ function safeEntryPath (destDir, fileName) {
12
+ const root = path.resolve(destDir);
13
+ const target = path.resolve(root, fileName);
14
+
15
+ if (target !== root && !target.startsWith(root + path.sep)) {
16
+ throw new Error(`Refusing zip entry outside extract root: ${fileName}`);
17
+ }
18
+
19
+ return target;
20
+ }
21
+
22
+ /**
23
+ * Whether a zip on disk contains every listed entry name (forward-slash paths).
24
+ * @param {string} zipPath
25
+ * @param {string[]} entryNames
26
+ * @returns {Promise<boolean>}
27
+ */
28
+ export function zipHasEntries (zipPath, entryNames) {
29
+ const needed = new Set(entryNames.map((n) => String(n).replace(/\\/g, '/')));
30
+
31
+ return new Promise((resolve, reject) => {
32
+ yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
33
+ if (openErr) {
34
+ reject(openErr);
35
+
36
+ return;
37
+ }
38
+
39
+ let settled = false;
40
+ const found = new Set();
41
+ const fail = (err) => {
42
+ if (!settled) {
43
+ settled = true;
44
+ reject(err);
45
+ }
46
+ };
47
+ const finish = (value) => {
48
+ if (!settled) {
49
+ settled = true;
50
+ resolve(value);
51
+ }
52
+ };
53
+
54
+ zipfile.on('error', fail);
55
+ zipfile.on('end', () => {
56
+ finish([...needed].every((n) => found.has(n)));
57
+ });
58
+ zipfile.readEntry();
59
+
60
+ zipfile.on('entry', (entry) => {
61
+ const name = String(entry.fileName || '').replace(/\\/g, '/');
62
+
63
+ if (needed.has(name)) {
64
+ found.add(name);
65
+ if (found.size === needed.size) {
66
+ // Close early once all required members are present.
67
+ try {
68
+ zipfile.close();
69
+ } catch {
70
+ // ignore
71
+ }
72
+ finish(true);
73
+
74
+ return;
75
+ }
76
+ }
77
+ zipfile.readEntry();
78
+ });
79
+ });
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Extract a zip on disk into destDir without holding the archive in RAM.
85
+ * Uses yauzl lazy entries + per-file streams.
86
+ * @param {string} zipPath
87
+ * @param {string} destDir
88
+ * @returns {Promise<void>}
89
+ */
90
+ export function extractZipFile (zipPath, destDir) {
91
+ fs.mkdirSync(destDir, {recursive: true});
92
+
93
+ return new Promise((resolve, reject) => {
94
+ yauzl.open(zipPath, {lazyEntries: true, autoClose: true}, (openErr, zipfile) => {
95
+ if (openErr) {
96
+ reject(openErr);
97
+
98
+ return;
99
+ }
100
+
101
+ let settled = false;
102
+ const fail = (err) => {
103
+ if (!settled) {
104
+ settled = true;
105
+ reject(err);
106
+ }
107
+ };
108
+ const done = () => {
109
+ if (!settled) {
110
+ settled = true;
111
+ resolve();
112
+ }
113
+ };
114
+
115
+ zipfile.on('error', fail);
116
+ zipfile.on('end', done);
117
+ zipfile.readEntry();
118
+
119
+ zipfile.on('entry', (entry) => {
120
+ const name = String(entry.fileName || '').replace(/\\/g, '/');
121
+
122
+ if (!name || name.includes('\0')) {
123
+ fail(new Error(`Invalid zip entry name: ${entry.fileName}`));
124
+
125
+ return;
126
+ }
127
+
128
+ let destPath;
129
+
130
+ try {
131
+ destPath = safeEntryPath(destDir, name);
132
+ } catch (err) {
133
+ fail(err);
134
+
135
+ return;
136
+ }
137
+
138
+ if (/\/$/u.test(name)) {
139
+ try {
140
+ fs.mkdirSync(destPath, {recursive: true});
141
+ } catch (err) {
142
+ fail(err);
143
+
144
+ return;
145
+ }
146
+ zipfile.readEntry();
147
+
148
+ return;
149
+ }
150
+
151
+ try {
152
+ fs.mkdirSync(path.dirname(destPath), {recursive: true});
153
+ } catch (err) {
154
+ fail(err);
155
+
156
+ return;
157
+ }
158
+
159
+ zipfile.openReadStream(entry, (streamErr, readStream) => {
160
+ if (streamErr) {
161
+ fail(streamErr);
162
+
163
+ return;
164
+ }
165
+ const writeStream = fs.createWriteStream(destPath);
166
+
167
+ pipeline(readStream, writeStream)
168
+ .then(() => {
169
+ zipfile.readEntry();
170
+ })
171
+ .catch(fail);
172
+ });
173
+ });
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Peek whether a file starts with the ZIP local-file magic (`PK`).
180
+ * @param {string} filePath
181
+ * @returns {boolean}
182
+ */
183
+ export function fileLooksLikeZip (filePath) {
184
+ const fd = fs.openSync(filePath, 'r');
185
+
186
+ try {
187
+ const buf = Buffer.alloc(4);
188
+ const n = fs.readSync(fd, buf, 0, 4, 0);
189
+
190
+ return n >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
191
+ } finally {
192
+ fs.closeSync(fd);
193
+ }
194
+ }
@@ -9,6 +9,7 @@
9
9
  * analyticsEnabled — when false, track is a no-op (default true when endpoint set)
10
10
  * analyticsPlatform — optional override; else window.gameConfig.platform
11
11
  * analyticsAppVersion — optional override; else window.gameConfig.version / config.appVersion
12
+ * (web/uwp/pwa/electron/capacitor prebuilds all write game-config.js)
12
13
  */
13
14
  (function registerAnalyticsHelpers(ns) {
14
15
  const OPT_OUT_KEY = 'adaptfully_analytics_opt_out';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",
@@ -58,6 +58,6 @@
58
58
  "dependencies": {
59
59
  "archiver": "^7.0.1",
60
60
  "axios": "^1.7.9",
61
- "unzip-stream": "^0.3.4"
61
+ "yauzl": "^3.4.0"
62
62
  }
63
63
  }