sfiledl 2.1.1 → 2.2.1

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/build/lib.cjs CHANGED
@@ -3,8 +3,10 @@
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  const path = require('path');
6
- const fs = require('fs/promises');
6
+ const fs$1 = require('fs');
7
+ const crypto = require('crypto');
7
8
  const playwright = require('playwright');
9
+ const fs = require('fs/promises');
8
10
  const os = require('os');
9
11
 
10
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -27,63 +29,95 @@ n.default = e;
27
29
  return n;
28
30
  }
29
31
 
30
- const path__default = /*#__PURE__*/_interopDefault(path);
32
+ const path__namespace = /*#__PURE__*/_interopNamespace(path);
31
33
  const fs__namespace = /*#__PURE__*/_interopNamespace(fs);
32
34
  const os__default = /*#__PURE__*/_interopDefault(os);
33
35
 
34
36
  class AppError extends Error {
35
- code;
36
- retryable;
37
37
  timestamp;
38
38
  context;
39
+ code;
40
+ retryable;
39
41
  constructor(message, code, retryable = false, context) {
40
42
  super(message);
43
+ this.name = this.constructor.name;
41
44
  this.code = code;
42
45
  this.retryable = retryable;
43
- this.name = this.constructor.name;
44
46
  this.timestamp = new Date().toISOString();
45
- this.context = context ? { ...context } : undefined;
46
- Error.captureStackTrace?.(this, this.constructor);
47
+ this.context = context ? Object.freeze({ ...context }) : Object.freeze({});
48
+ if (Error.captureStackTrace) {
49
+ Error.captureStackTrace(this, this.constructor);
50
+ }
51
+ Object.freeze(this);
47
52
  }
48
53
  toJSON() {
49
54
  return {
50
55
  name: this.name,
51
- message: this.message,
52
56
  code: this.code,
57
+ message: this.message,
53
58
  retryable: this.retryable,
54
59
  timestamp: this.timestamp,
55
60
  context: this.context,
61
+ stack: process.env['NODE_ENV'] === 'development' ? this.stack : undefined,
56
62
  };
57
63
  }
64
+ toString() {
65
+ return `${this.name} [${this.code}]: ${this.message}`;
66
+ }
67
+ isCode(code) {
68
+ return this.code === code;
69
+ }
70
+ isRetryable() {
71
+ return this.retryable;
72
+ }
58
73
  }
59
74
 
60
75
  class ValidationError extends AppError {
61
76
  constructor(message, context) {
62
77
  super(message, 'VALIDATION_ERROR', false, context);
78
+ Object.freeze(this);
63
79
  }
64
80
  }
65
81
  class NetworkError extends AppError {
66
82
  constructor(message, context) {
67
83
  super(message, 'NETWORK_ERROR', true, context);
84
+ Object.freeze(this);
68
85
  }
69
86
  }
70
87
  class FileError extends AppError {
71
88
  constructor(message, context) {
72
89
  super(message, 'FILE_ERROR', false, context);
90
+ Object.freeze(this);
73
91
  }
74
92
  }
75
93
  class BrowserError extends AppError {
76
94
  constructor(message, context) {
77
95
  super(message, 'BROWSER_ERROR', true, context);
96
+ Object.freeze(this);
78
97
  }
79
98
  }
99
+ function isAppError(err) {
100
+ return err instanceof AppError;
101
+ }
102
+ function isRetryableError(err) {
103
+ return isAppError(err) && err.retryable;
104
+ }
105
+ function isErrorWithCode(err, code) {
106
+ return isAppError(err) && err.code === code;
107
+ }
80
108
 
81
109
  const DEFAULTS = {
82
110
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
83
111
  headless: true,
84
- timeout: 100000,
85
- downloadButtonTimeout: 100000,
86
- fallbackWaitMs: 5000,
112
+ timeout: 60000,
113
+ downloadButtonTimeout: 30000,
114
+ fallbackWaitMs: 3000,
115
+ retries: 3,
116
+ retryDelay: 1000,
117
+ maxRetryDelay: 30000,
118
+ saveDebugArtifacts: true,
119
+ maxFilenameLength: 255,
120
+ sanitizeReplacement: '_',
87
121
  };
88
122
 
89
123
  class SfilePageInteractions {
@@ -94,46 +128,110 @@ class SfilePageInteractions {
94
128
  this.logger = logger;
95
129
  }
96
130
  async waitForDownloadButton(timeout) {
97
- this.logger.debug('Waiting for #download button to be visible');
131
+ this.logger.debug('Waiting for #download button', { timeout });
98
132
  const button = this.page.locator('#download');
99
133
  await button.waitFor({ state: 'visible', timeout });
100
- this.logger.debug('Waiting for button to become active (href != "#" and pointerEvents != "none")');
134
+ this.logger.debug('Button is visible, checking if active');
101
135
  await this.page.waitForFunction(() => {
102
136
  const btn = document.querySelector('#download');
103
137
  if (!btn)
104
138
  return false;
105
139
  const href = btn.getAttribute('href');
106
140
  const style = window.getComputedStyle(btn);
107
- return href && href !== '#' && style.pointerEvents !== 'none';
141
+ const isDisabled = btn.hasAttribute('disabled') ||
142
+ btn.getAttribute('aria-disabled') === 'true' ||
143
+ btn.classList.contains('disabled');
144
+ return !!(href &&
145
+ href !== '#' &&
146
+ href.trim() !== '' &&
147
+ style.pointerEvents !== 'none' &&
148
+ !isDisabled);
108
149
  }, { timeout });
150
+ this.logger.debug('Download button is active and ready');
109
151
  }
110
152
  async extractIntermediateUrl() {
111
- this.logger.debug('Extracting href from #download');
112
- const href = await this.page.$eval('#download', (el) => el.href);
113
- if (!href || href === '#') {
114
- throw new NetworkError('Download button href is invalid', { href });
153
+ this.logger.debug('Extracting href from #download button');
154
+ try {
155
+ const href = await this.page.$eval('#download', (el) => {
156
+ const anchor = el;
157
+ return anchor.href;
158
+ });
159
+ if (!href || href === '#' || href.trim() === '') {
160
+ throw new NetworkError('Download button href is empty or invalid', { href });
161
+ }
162
+ return href;
163
+ }
164
+ catch (err) {
165
+ if (err instanceof Error && err.message?.includes('Element not found')) {
166
+ throw new NetworkError('Download button (#download) not found in page', {
167
+ selector: '#download',
168
+ originalError: err.message,
169
+ });
170
+ }
171
+ throw err;
115
172
  }
116
- return href;
117
173
  }
118
174
  }
119
175
 
120
176
  function sanitizeFilename(name, replacement = '_') {
121
- const sanitized = name
177
+ const safeName = name.replace(/\.\.[\\/]/g, '');
178
+ const sanitized = safeName
122
179
  .replace(/[<>:"/\\|?*\x00-\x1F]/g, replacement)
123
180
  .replace(new RegExp(`${replacement}+`, 'g'), replacement)
124
- .trim()
125
- .slice(0, 255);
126
- return sanitized || 'file.bin';
181
+ .trim();
182
+ const maxLength = 255;
183
+ const trimmed = sanitized.slice(0, maxLength);
184
+ return trimmed || `file${replacement}bin`;
127
185
  }
128
186
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
129
- function safeStringify(value) {
187
+ function safeStringify(value, indent = 0) {
130
188
  try {
131
- return JSON.stringify(value);
189
+ const seen = new WeakSet();
190
+ return JSON.stringify(value, (_key, val) => {
191
+ if (typeof val === 'object' && val !== null) {
192
+ if (seen.has(val))
193
+ return '[Circular]';
194
+ seen.add(val);
195
+ }
196
+ if (typeof val === 'bigint')
197
+ return val.toString() + 'n';
198
+ if (val instanceof Error) {
199
+ return { name: val.name, message: val.message, stack: val.stack };
200
+ }
201
+ return val;
202
+ }, indent);
132
203
  }
133
204
  catch {
134
205
  return '[unserializable]';
135
206
  }
136
207
  }
208
+ function extractFilenameFromContentDisposition(header) {
209
+ if (!header)
210
+ return null;
211
+ const encodedMatch = header.match(/filename\*=UTF-8''([^;\n"]+)/i);
212
+ if (encodedMatch?.[1]) {
213
+ try {
214
+ return decodeURIComponent(encodedMatch[1]);
215
+ }
216
+ catch { }
217
+ }
218
+ const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i);
219
+ if (quotedMatch?.[1])
220
+ return quotedMatch[1];
221
+ const plainMatch = header.match(/filename\s*=\s*([^;,\n"]+)/i);
222
+ if (plainMatch?.[1])
223
+ return plainMatch[1].trim();
224
+ return null;
225
+ }
226
+ function calculateRetryDelay(attempt, baseDelayMs, maxDelayMs = 30000) {
227
+ const exponential = baseDelayMs * Math.pow(2, attempt - 1);
228
+ const jitter = Math.random() * 0.3 * exponential;
229
+ return Math.min(exponential + jitter, maxDelayMs);
230
+ }
231
+ function isError(value) {
232
+ return (value instanceof Error ||
233
+ (typeof value === 'object' && value !== null && 'message' in value));
234
+ }
137
235
 
138
236
  class BrowserManager {
139
237
  logger;
@@ -143,21 +241,36 @@ class BrowserManager {
143
241
  page = null;
144
242
  interactions = null;
145
243
  debugDir = null;
244
+ stageHistory = [];
146
245
  constructor(logger, opts) {
147
246
  this.logger = logger;
148
247
  this.opts = opts;
149
248
  }
249
+ trackStage(stage) {
250
+ this.stageHistory.push({ stage, ts: Date.now() });
251
+ }
150
252
  async launch() {
253
+ const stage = 'launch';
254
+ this.trackStage(stage);
151
255
  try {
152
256
  this.logger.info('Launching browser', { headless: this.opts.headless });
153
257
  this.browser = await playwright.chromium.launch({
154
258
  headless: this.opts.headless,
155
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
259
+ args: [
260
+ '--no-sandbox',
261
+ '--disable-setuid-sandbox',
262
+ '--disable-dev-shm-usage',
263
+ '--disable-accelerated-2d-canvas',
264
+ '--no-first-run',
265
+ '--no-zygote',
266
+ ],
156
267
  });
157
268
  this.context = await this.browser.newContext({
158
269
  userAgent: this.opts.userAgent,
159
270
  acceptDownloads: true,
160
271
  locale: 'en-US',
272
+ timezoneId: 'UTC',
273
+ viewport: { width: 1280, height: 720 },
161
274
  });
162
275
  await this.context.addCookies([
163
276
  {
@@ -171,216 +284,667 @@ class BrowserManager {
171
284
  this.page = await this.context.newPage();
172
285
  this.interactions = new SfilePageInteractions(this.page, this.logger);
173
286
  if (this.opts.debug) {
174
- this.page.on('console', (msg) => {
175
- if (msg.type() === 'error')
176
- this.logger.error(`[console] ${msg.text()}`);
177
- else if (msg.type() === 'warning')
178
- this.logger.warn(`[console] ${msg.text()}`);
179
- });
180
- this.page.on('pageerror', (err) => this.logger.error(`[pageerror] ${err.message}`));
287
+ this.setupDebugListeners();
181
288
  }
182
289
  this.logger.info('Browser ready');
183
290
  }
184
291
  catch (err) {
185
- throw new BrowserError(`Failed to launch browser: ${err.message}`);
292
+ await this.handleStageError(stage, err);
293
+ throw new BrowserError(`Failed to launch browser: ${err.message}`, {
294
+ stage,
295
+ originalError: err.message,
296
+ userAgent: this.opts.userAgent,
297
+ });
186
298
  }
187
299
  }
188
- async goto(url, waitUntil = 'networkidle') {
300
+ setupDebugListeners() {
189
301
  if (!this.page)
190
- throw new BrowserError('Page not initialized');
302
+ return;
303
+ this.page.on('console', (msg) => {
304
+ const type = msg.type();
305
+ const text = msg.text();
306
+ if (type === 'error') {
307
+ this.logger.error(`[console] ${text}`, { location: msg.location() });
308
+ }
309
+ else if (type === 'warning') {
310
+ this.logger.warn(`[console] ${text}`);
311
+ }
312
+ else if (type === 'debug') {
313
+ this.logger.debug(`[console] ${text}`);
314
+ }
315
+ });
316
+ this.page.on('pageerror', (err) => {
317
+ this.logger.error('[pageerror]', undefined, err);
318
+ });
319
+ this.page.on('requestfailed', (req) => {
320
+ const failure = req.failure();
321
+ this.logger.warn('[requestfailed]', {
322
+ url: req.url(),
323
+ method: req.method(),
324
+ error: failure?.errorText,
325
+ });
326
+ });
327
+ }
328
+ async goto(url, waitUntil = 'networkidle') {
329
+ const stage = 'navigation';
330
+ this.trackStage(stage);
331
+ if (!this.page) {
332
+ throw new BrowserError('Page not initialized', { stage });
333
+ }
191
334
  try {
192
- this.logger.debug(`Navigating to ${url} (waitUntil=${waitUntil})`);
335
+ this.logger.debug(`Navigating to ${url}`, { waitUntil, timeout: this.opts.timeout });
193
336
  await this.page.goto(url, { waitUntil, timeout: this.opts.timeout });
194
337
  }
195
338
  catch (err) {
196
- throw new NetworkError(`Navigation failed: ${err.message}`, { url });
339
+ await this.handleStageError(stage, err);
340
+ throw new NetworkError(`Navigation failed: ${err.message}`, {
341
+ url,
342
+ stage,
343
+ waitUntil,
344
+ timeout: this.opts.timeout,
345
+ originalError: err.message,
346
+ });
197
347
  }
198
348
  }
199
349
  async waitForDownloadButton() {
200
- if (!this.interactions)
201
- throw new BrowserError('Interactions not ready');
202
- await this.interactions.waitForDownloadButton(DEFAULTS.downloadButtonTimeout);
350
+ const stage = 'waitForButton';
351
+ this.trackStage(stage);
352
+ if (!this.interactions) {
353
+ throw new BrowserError('Interactions not ready', { stage });
354
+ }
355
+ const timeout = this.opts.downloadButtonTimeout ?? DEFAULTS.downloadButtonTimeout;
356
+ try {
357
+ await this.interactions.waitForDownloadButton(timeout);
358
+ }
359
+ catch (err) {
360
+ await this.handleStageError(stage, err);
361
+ throw new NetworkError(`Failed to wait for download button: ${err.message}`, {
362
+ stage,
363
+ timeout,
364
+ originalError: err.message,
365
+ });
366
+ }
203
367
  }
204
368
  async getIntermediateUrl() {
205
- if (!this.interactions)
206
- throw new BrowserError('Interactions not ready');
207
- return this.interactions.extractIntermediateUrl();
369
+ const stage = 'extractUrl';
370
+ this.trackStage(stage);
371
+ if (!this.interactions) {
372
+ throw new BrowserError('Interactions not ready', { stage });
373
+ }
374
+ try {
375
+ const url = await this.interactions.extractIntermediateUrl();
376
+ this.logger.debug('Intermediate URL extracted', { url });
377
+ return url;
378
+ }
379
+ catch (err) {
380
+ await this.handleStageError(stage, err);
381
+ throw new NetworkError(`Failed to extract intermediate URL: ${err.message}`, {
382
+ stage,
383
+ originalError: err.message,
384
+ });
385
+ }
208
386
  }
209
387
  async startDownloadAndWait(autoUrl) {
210
- if (!this.page)
211
- throw new BrowserError('Page not initialized');
388
+ const stage = 'downloadWait';
389
+ this.trackStage(stage);
390
+ if (!this.page) {
391
+ throw new BrowserError('Page not initialized', { stage });
392
+ }
212
393
  const downloadPromise = this.page
213
394
  .waitForEvent('download', { timeout: this.opts.timeout })
214
395
  .catch((err) => {
215
- this.logger.warn(`Download event wait failed: ${err.message}`);
396
+ this.logger.debug('Download event timeout', {
397
+ timeout: this.opts.timeout,
398
+ error: err.message,
399
+ });
216
400
  return null;
217
401
  });
218
- this.logger.debug(`Navigating to auto URL: ${autoUrl}`);
219
- await this.page.goto(autoUrl, { waitUntil: 'commit', timeout: this.opts.timeout });
402
+ this.logger.debug('Navigating to auto download URL', { url: autoUrl });
403
+ await this.page
404
+ .goto(autoUrl, { waitUntil: 'commit', timeout: this.opts.timeout })
405
+ .catch((err) => {
406
+ this.logger.warn('Navigation to auto URL failed, continuing anyway', {
407
+ error: err.message,
408
+ });
409
+ });
220
410
  const download = await downloadPromise;
221
411
  if (download) {
222
412
  this.logger.info('Download event captured');
223
413
  return download;
224
414
  }
415
+ this.logger.debug('No download event captured within timeout');
225
416
  return null;
226
417
  }
227
418
  async fallbackCollectFileResponse() {
228
- if (!this.page)
229
- throw new BrowserError('Page not initialized');
230
- this.logger.warn('Falling back to response interception');
419
+ const stage = 'fallbackIntercept';
420
+ this.trackStage(stage);
421
+ if (!this.page) {
422
+ throw new BrowserError('Page not initialized', { stage });
423
+ }
424
+ this.logger.info('Falling back to response interception');
231
425
  const responses = [];
232
426
  const handler = (res) => responses.push(res);
233
427
  this.page.on('response', handler);
234
428
  await sleep(DEFAULTS.fallbackWaitMs);
235
429
  this.page.off('response', handler);
236
430
  const fileResponse = [...responses].reverse().find((r) => {
237
- const disposition = r.headers()['content-disposition'];
431
+ const headers = r.headers();
432
+ const disposition = headers['content-disposition'];
433
+ const contentType = headers['content-type'];
434
+ const url = r.url();
238
435
  return ((disposition && disposition.includes('attachment')) ||
239
- r.url().includes('/downloadfile/'));
436
+ url.includes('/downloadfile/') ||
437
+ (contentType &&
438
+ !contentType.startsWith('text/html') &&
439
+ !contentType.startsWith('application/json')));
240
440
  });
241
- if (!fileResponse)
441
+ if (!fileResponse) {
442
+ this.logger.debug('No suitable file response found in fallback', {
443
+ responseCount: responses.length,
444
+ sampleUrls: responses.slice(0, 3).map((r) => r.url()),
445
+ });
446
+ return null;
447
+ }
448
+ try {
449
+ const buffer = await fileResponse.body();
450
+ let filename = extractFilenameFromContentDisposition(fileResponse.headers()['content-disposition']);
451
+ if (!filename) {
452
+ const urlParts = fileResponse.url().split('/');
453
+ filename = urlParts[urlParts.length - 1]?.split('?')[0] || 'file.bin';
454
+ }
455
+ filename = filename.replace(/[<>:"/\\|?*]/g, '_');
456
+ this.logger.info('Fallback response captured', {
457
+ filename,
458
+ size: buffer.length,
459
+ contentType: fileResponse.headers()['content-type'],
460
+ });
461
+ return { buffer, filename };
462
+ }
463
+ catch (err) {
464
+ this.logger.error('Failed to read fallback response body', { error: err.message });
242
465
  return null;
243
- const buffer = await fileResponse.body();
244
- let filename = fileResponse.url().split('/').pop()?.split('?')[0] || 'file.bin';
245
- filename = filename.replace(/[<>:"/\\|?*]/g, '_');
246
- return { buffer, filename };
466
+ }
247
467
  }
248
468
  async saveDebugArtifacts(errorMessage) {
249
- if (!this.page)
250
- return;
469
+ if (!this.opts.debug || !this.page)
470
+ return null;
251
471
  try {
252
- this.debugDir = path__default.default.join(os__default.default.tmpdir(), `sfile_debug_${Date.now()}`);
472
+ this.debugDir = path__namespace.default.join(os__default.default.tmpdir(), `sfile_debug_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
253
473
  await fs__namespace.mkdir(this.debugDir, { recursive: true });
254
- await Promise.all([
255
- this.page.screenshot({
256
- path: path__default.default.join(this.debugDir, 'error.png'),
474
+ const tasks = [
475
+ this.page
476
+ .screenshot({
477
+ path: path__namespace.default.join(this.debugDir, 'error.png'),
257
478
  fullPage: true,
258
- }),
259
- fs__namespace.writeFile(path__default.default.join(this.debugDir, 'error.html'), await this.page.content()),
260
- fs__namespace.writeFile(path__default.default.join(this.debugDir, 'error.txt'), errorMessage),
261
- ]);
262
- this.logger.info(`Debug artifacts saved to ${this.debugDir}`);
479
+ })
480
+ .then(() => { }),
481
+ fs__namespace.writeFile(path__namespace.default.join(this.debugDir, 'error.html'), await this.page.content()),
482
+ fs__namespace.writeFile(path__namespace.default.join(this.debugDir, 'error.txt'), errorMessage),
483
+ fs__namespace.writeFile(path__namespace.default.join(this.debugDir, 'stages.json'), JSON.stringify(this.stageHistory, null, 2)),
484
+ ];
485
+ await Promise.all(tasks);
486
+ this.logger.info('Debug artifacts saved', { debugDir: this.debugDir });
487
+ return this.debugDir;
263
488
  }
264
489
  catch (e) {
265
- this.logger.error(`Failed to save debug artifacts: ${e.message}`);
490
+ this.logger.error('Failed to save debug artifacts', { error: e.message });
491
+ return null;
492
+ }
493
+ }
494
+ async handleStageError(stage, error) {
495
+ if (this.opts.debug) {
496
+ await this.saveDebugArtifacts(`Error at stage "${stage}": ${error?.message || 'Unknown error'}`);
266
497
  }
267
498
  }
268
499
  async close() {
269
- if (this.page)
270
- await this.page.close().catch(() => { });
271
- if (this.context)
272
- await this.context.close().catch(() => { });
273
- if (this.browser)
274
- await this.browser.close().catch(() => { });
275
- this.logger.debug('Browser closed');
500
+ const closePromises = [
501
+ this.page?.close().catch(() => { }),
502
+ this.context?.close().catch(() => { }),
503
+ this.browser?.close().catch(() => { }),
504
+ ].filter(Boolean);
505
+ await Promise.all(closePromises);
506
+ this.logger.debug('Browser closed', { stages: this.stageHistory.length });
507
+ }
508
+ async getPage() {
509
+ return this.page;
510
+ }
511
+ getDebugDir() {
512
+ return this.debugDir;
513
+ }
514
+ getStageHistory() {
515
+ return [...this.stageHistory];
276
516
  }
277
517
  }
278
518
 
279
519
  class Logger {
280
520
  minLevel;
281
- constructor(debugMode = false) {
282
- this.minLevel = debugMode ? 'debug' : 'info';
521
+ correlationId;
522
+ fileStream = null;
523
+ prefix;
524
+ constructor(options = {}) {
525
+ this.minLevel = options.debugMode ? 'debug' : 'info';
526
+ this.correlationId = options.correlationId;
527
+ this.prefix = options.prefix || '';
528
+ if (options.logFile) {
529
+ this.fileStream = fs$1.createWriteStream(options.logFile, { flags: 'a' });
530
+ }
531
+ }
532
+ setCorrelationId(id) {
533
+ this.correlationId = id;
283
534
  }
284
535
  setDebug(enabled) {
285
536
  this.minLevel = enabled ? 'debug' : 'info';
286
537
  }
538
+ levelPriority(level) {
539
+ return { debug: 0, info: 1, warn: 2, error: 3 }[level];
540
+ }
287
541
  shouldLog(level) {
288
- const levels = { debug: 0, info: 1, warn: 2, error: 3 };
289
- return levels[level] >= levels[this.minLevel];
542
+ return this.levelPriority(level) >= this.levelPriority(this.minLevel);
290
543
  }
291
- log(level, message, context) {
544
+ format(level, message, context) {
545
+ const ts = new Date().toISOString();
546
+ const corr = this.correlationId ? ` [${this.correlationId}]` : '';
547
+ const prefix = this.prefix ? ` [${this.prefix}]` : '';
548
+ const ctx = context ? ` | ${safeStringify(context)}` : '';
549
+ return `[${ts}]${corr}${prefix} ${level.toUpperCase()}: ${message}${ctx}`;
550
+ }
551
+ write(level, message, context) {
292
552
  if (!this.shouldLog(level))
293
553
  return;
294
- const ts = new Date().toISOString();
295
- const ctx = context ? ` ${safeStringify(context)}` : '';
296
- console.log(`[${ts}] ${level.toUpperCase()}: ${message}${ctx}`);
554
+ const formatted = this.format(level, message, context);
555
+ const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log';
556
+ console[consoleMethod](formatted);
557
+ if (this.fileStream?.writable) {
558
+ this.fileStream.write(formatted + '\n');
559
+ }
297
560
  }
298
561
  debug(msg, ctx) {
299
- this.log('debug', msg, ctx);
562
+ this.write('debug', msg, ctx);
300
563
  }
301
564
  info(msg, ctx) {
302
- this.log('info', msg, ctx);
565
+ this.write('info', msg, ctx);
303
566
  }
304
567
  warn(msg, ctx) {
305
- this.log('warn', msg, ctx);
568
+ this.write('warn', msg, ctx);
569
+ }
570
+ error(msg, ctx, err) {
571
+ let errorCtx;
572
+ if (err) {
573
+ const baseObj = ctx && typeof ctx === 'object' && ctx !== null
574
+ ? { ...ctx }
575
+ : {};
576
+ errorCtx = {
577
+ ...baseObj,
578
+ error: { name: err.name, message: err.message },
579
+ };
580
+ }
581
+ else {
582
+ errorCtx = ctx;
583
+ }
584
+ this.write('error', msg, errorCtx);
585
+ }
586
+ child(prefix) {
587
+ const childOptions = {
588
+ debugMode: this.minLevel === 'debug',
589
+ ...(this.correlationId !== undefined && { correlationId: this.correlationId }),
590
+ prefix: this.prefix ? `${this.prefix}:${prefix}` : prefix,
591
+ };
592
+ const child = new Logger(childOptions);
593
+ if (this.fileStream) {
594
+ child.fileStream = this.fileStream;
595
+ }
596
+ return child;
306
597
  }
307
- error(msg, ctx) {
308
- this.log('error', msg, ctx);
598
+ async close() {
599
+ if (this.fileStream && !this.fileStream.closed) {
600
+ return new Promise((resolve) => this.fileStream?.end(resolve));
601
+ }
309
602
  }
310
603
  }
311
604
 
312
605
  function normalizeOptions(opts) {
313
- return {
606
+ const base = {
314
607
  headless: opts?.headless ?? DEFAULTS.headless,
315
- debug: opts?.debug ?? false,
316
608
  userAgent: opts?.userAgent ?? DEFAULTS.userAgent,
317
609
  timeout: opts?.timeout ?? DEFAULTS.timeout,
610
+ downloadButtonTimeout: opts?.downloadButtonTimeout ?? DEFAULTS.downloadButtonTimeout,
611
+ retries: opts?.retries ?? DEFAULTS.retries,
612
+ retryDelay: opts?.retryDelay ?? DEFAULTS.retryDelay,
613
+ debug: opts?.debug ?? false,
614
+ saveDebugArtifacts: opts?.saveDebugArtifacts ?? DEFAULTS.saveDebugArtifacts,
615
+ };
616
+ const optional = {
617
+ ...(opts?.onProgress !== undefined && { onProgress: opts.onProgress }),
618
+ ...(opts?.correlationId !== undefined && { correlationId: opts.correlationId }),
619
+ ...(opts?._internal !== undefined && { _internal: opts._internal }),
620
+ ...(opts?.logFile !== undefined && { logFile: opts.logFile }),
318
621
  };
622
+ return { ...base, ...optional };
319
623
  }
320
624
 
321
- async function downloadSfile(url, saveDir, options) {
322
- const opts = normalizeOptions(options);
323
- const logger = new Logger(opts.debug);
324
- if (!url || !url.includes('sfile.co')) {
325
- throw new ValidationError('URL must contain sfile.co', { url });
625
+ class InputValidator {
626
+ static validateUrl(url, allowedDomains = ['sfile.co', 'sfile.mobi']) {
627
+ if (!url || typeof url !== 'string') {
628
+ throw new ValidationError('URL must be a non-empty string', {
629
+ received: typeof url,
630
+ url,
631
+ });
632
+ }
633
+ const hasAllowedDomain = allowedDomains.some((domain) => url.includes(domain));
634
+ if (!hasAllowedDomain) {
635
+ throw new ValidationError(`URL must contain one of: ${allowedDomains.join(', ')}`, {
636
+ url,
637
+ allowedDomains,
638
+ });
639
+ }
640
+ try {
641
+ const parsed = new URL(url);
642
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
643
+ throw new ValidationError('URL must use http or https protocol', {
644
+ url,
645
+ protocol: parsed.protocol,
646
+ });
647
+ }
648
+ }
649
+ catch (parseErr) {
650
+ throw new ValidationError('Invalid URL format', {
651
+ url,
652
+ parseError: parseErr instanceof Error ? parseErr.message : String(parseErr),
653
+ });
654
+ }
655
+ }
656
+ static validateSaveDir(dir) {
657
+ if (!dir || typeof dir !== 'string') {
658
+ throw new ValidationError('Save directory must be a non-empty string', {
659
+ received: typeof dir,
660
+ dir,
661
+ });
662
+ }
663
+ }
664
+ static sanitizeFilename(name) {
665
+ const replacement = DEFAULTS.sanitizeReplacement;
666
+ const maxLength = DEFAULTS.maxFilenameLength;
667
+ const sanitized = sanitizeFilename(name, replacement);
668
+ if (!sanitized || sanitized.length === 0) {
669
+ const ext = name.includes('.') ? name.split('.').pop() : 'bin';
670
+ return `downloaded_file${ext ? '.' + ext : ''}`.slice(0, maxLength);
671
+ }
672
+ return sanitized.slice(0, maxLength);
673
+ }
674
+ static validateOptions(options) {
675
+ if (options && typeof options !== 'object') {
676
+ throw new ValidationError('Options must be an object or undefined', {
677
+ received: typeof options,
678
+ });
679
+ }
680
+ const opts = options;
681
+ const numericFields = ['timeout', 'retries', 'retryDelay', 'downloadButtonTimeout'];
682
+ for (const field of numericFields) {
683
+ if (field in opts && typeof opts[field] !== 'number') {
684
+ throw new ValidationError(`Option '${field}' must be a number`, {
685
+ field,
686
+ received: typeof opts[field],
687
+ value: opts[field],
688
+ });
689
+ }
690
+ }
691
+ const booleanFields = ['headless', 'debug', 'saveDebugArtifacts'];
692
+ for (const field of booleanFields) {
693
+ if (field in opts && typeof opts[field] !== 'boolean') {
694
+ throw new ValidationError(`Option '${field}' must be a boolean`, {
695
+ field,
696
+ received: typeof opts[field],
697
+ value: opts[field],
698
+ });
699
+ }
700
+ }
326
701
  }
327
- await fs__namespace.default.mkdir(saveDir, { recursive: true });
328
- const browserMgr = new BrowserManager(logger, {
702
+ }
703
+
704
+ function stripUndefined(obj) {
705
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
706
+ }
707
+ function safeProgress(onProgress, percent, total, meta) {
708
+ if (typeof onProgress === 'function') {
709
+ try {
710
+ onProgress(percent, total, meta);
711
+ }
712
+ catch (err) {
713
+ console.warn('Progress callback error:', err);
714
+ }
715
+ }
716
+ }
717
+ async function executeDownload(url, saveDir, opts, logger) {
718
+ const startTime = Date.now();
719
+ const browserMgr = new BrowserManager(logger.child('BrowserManager'), {
329
720
  headless: opts.headless,
330
721
  userAgent: opts.userAgent,
331
722
  timeout: opts.timeout,
332
723
  debug: opts.debug,
724
+ downloadButtonTimeout: opts.downloadButtonTimeout,
333
725
  });
726
+ let finalPath;
727
+ let fileSize;
728
+ let method;
334
729
  try {
730
+ logger.info('Starting download workflow', { url });
731
+ safeProgress(opts.onProgress, 10, 100, {
732
+ stage: 'launch',
733
+ message: 'Launching browser',
734
+ attempt: 1,
735
+ });
335
736
  await browserMgr.launch();
336
737
  await browserMgr.goto(url, 'networkidle');
738
+ safeProgress(opts.onProgress, 30, 100, { stage: 'navigation', message: 'Page loaded' });
337
739
  await browserMgr.waitForDownloadButton();
740
+ safeProgress(opts.onProgress, 50, 100, {
741
+ stage: 'button',
742
+ message: 'Download button ready',
743
+ });
338
744
  const intermediateUrl = await browserMgr.getIntermediateUrl();
339
745
  const autoUrl = intermediateUrl.includes('?')
340
746
  ? `${intermediateUrl}&auto=1`
341
747
  : `${intermediateUrl}?auto=1`;
748
+ logger.debug('Triggering download', { autoUrl });
749
+ safeProgress(opts.onProgress, 70, 100, { stage: 'trigger', message: 'Download triggered' });
342
750
  let download = await browserMgr.startDownloadAndWait(autoUrl);
343
- let finalPath;
344
- let fileSize;
345
- let method;
346
751
  if (download) {
347
752
  const suggested = download.suggestedFilename() || 'file.bin';
348
- const filename = sanitizeFilename(suggested);
349
- finalPath = path__default.default.join(saveDir, filename);
753
+ const filename = InputValidator.sanitizeFilename(suggested);
754
+ finalPath = path__namespace.join(saveDir, filename);
350
755
  await download.saveAs(finalPath);
351
- const stat = await fs__namespace.default.stat(finalPath);
756
+ const stat = await fs$1.promises.stat(finalPath);
352
757
  fileSize = stat.size;
353
758
  method = 'direct';
354
- logger.info(`Saved via direct download: ${finalPath} (${fileSize} bytes)`);
759
+ logger.info('Saved via direct download', { path: finalPath, size: fileSize });
355
760
  }
356
761
  else {
762
+ logger.warn('Direct download not captured, trying fallback');
357
763
  const fallback = await browserMgr.fallbackCollectFileResponse();
358
764
  if (!fallback) {
359
- throw new NetworkError('No download event and no file response found');
765
+ throw new NetworkError('No download event and no file response found', {
766
+ url,
767
+ intermediateUrl,
768
+ autoUrl,
769
+ stage: 'fallbackFailed',
770
+ });
360
771
  }
361
772
  const { buffer, filename: rawName } = fallback;
362
- const filename = sanitizeFilename(rawName);
363
- finalPath = path__default.default.join(saveDir, filename);
364
- await fs__namespace.default.writeFile(finalPath, buffer);
773
+ const filename = InputValidator.sanitizeFilename(rawName);
774
+ finalPath = path__namespace.join(saveDir, filename);
775
+ await fs$1.promises.writeFile(finalPath, buffer);
365
776
  fileSize = buffer.length;
366
777
  method = 'fallback';
367
- logger.info(`Saved via fallback: ${finalPath} (${fileSize} bytes)`);
778
+ logger.info('Saved via fallback', { path: finalPath, size: fileSize });
779
+ }
780
+ safeProgress(opts.onProgress, 100, 100, { stage: 'complete', message: 'Download finished' });
781
+ const result = {
782
+ filePath: finalPath,
783
+ size: fileSize,
784
+ method,
785
+ durationMs: Date.now() - startTime,
786
+ attempts: 1,
787
+ };
788
+ if (opts.correlationId !== undefined) {
789
+ result.correlationId = opts.correlationId;
368
790
  }
369
- return { filePath: finalPath, size: fileSize, method };
791
+ return result;
370
792
  }
371
793
  catch (err) {
372
- await browserMgr.saveDebugArtifacts(err.message);
373
- throw err;
794
+ const error = err instanceof Error ? err : new Error(String(err));
795
+ const appError = error instanceof AppError
796
+ ? error
797
+ : new NetworkError(error.message, {
798
+ url,
799
+ saveDir,
800
+ correlationId: opts.correlationId,
801
+ originalError: error.message,
802
+ errorName: error.name,
803
+ });
804
+ if (opts.saveDebugArtifacts) {
805
+ const debugPath = await browserMgr.saveDebugArtifacts(appError.message);
806
+ if (debugPath) {
807
+ appError.debugPath = debugPath;
808
+ if (appError.context && typeof appError.context === 'object') {
809
+ appError.context.debugPath = debugPath;
810
+ }
811
+ }
812
+ }
813
+ throw appError;
374
814
  }
375
815
  finally {
376
- await browserMgr.close();
816
+ await browserMgr
817
+ .close()
818
+ .catch((e) => logger.error('Failed to close browser', { error: e.message }));
819
+ await logger.close().catch((e) => console.error('Failed to close logger', e));
820
+ }
821
+ }
822
+ async function downloadSfile(url, saveDir, options) {
823
+ const opts = normalizeOptions(options);
824
+ const correlationId = opts.correlationId || crypto.randomUUID();
825
+ const loggerOptions = {
826
+ debugMode: opts.debug,
827
+ correlationId,
828
+ prefix: 'downloadSfile',
829
+ };
830
+ if (opts.logFile !== undefined) {
831
+ loggerOptions.logFile = opts.logFile;
832
+ }
833
+ const logger = new Logger(loggerOptions);
834
+ InputValidator.validateUrl(url);
835
+ InputValidator.validateSaveDir(saveDir);
836
+ InputValidator.validateOptions(options);
837
+ await fs$1.promises.mkdir(saveDir, { recursive: true });
838
+ let lastError;
839
+ let attempt = 0;
840
+ while (attempt < opts.retries) {
841
+ attempt++;
842
+ try {
843
+ logger.info(`Download attempt ${attempt}/${opts.retries}`, { url });
844
+ const result = await executeDownload(url, saveDir, opts, logger);
845
+ logger.info(`Download succeeded`, {
846
+ duration: result.durationMs,
847
+ attempts: attempt,
848
+ method: result.method,
849
+ });
850
+ return {
851
+ ...result,
852
+ attempts: attempt,
853
+ correlationId,
854
+ };
855
+ }
856
+ catch (err) {
857
+ lastError = err instanceof Error ? err : new Error(String(err));
858
+ const isRetryable = lastError instanceof AppError && lastError.retryable;
859
+ const isLastAttempt = attempt >= opts.retries;
860
+ if (!isRetryable || isLastAttempt) {
861
+ logger.error(`Download failed permanently`, {
862
+ error: lastError.message,
863
+ attempt,
864
+ retryable: isRetryable,
865
+ lastAttempt: isLastAttempt,
866
+ });
867
+ throw lastError;
868
+ }
869
+ const delay = calculateRetryDelay(attempt, opts.retryDelay, DEFAULTS.maxRetryDelay);
870
+ logger.warn(`Retrying download`, {
871
+ attempt,
872
+ maxAttempts: opts.retries,
873
+ delayMs: Math.round(delay),
874
+ error: lastError.message,
875
+ });
876
+ safeProgress(opts.onProgress, 0, 100, {
877
+ stage: 'retry',
878
+ message: `Retry ${attempt}/${opts.retries}`,
879
+ attempt,
880
+ });
881
+ await sleep(delay);
882
+ }
883
+ }
884
+ throw lastError || new Error('Download failed after all retries');
885
+ }
886
+ async function downloadSfileSafe(url, saveDir, options) {
887
+ try {
888
+ const result = await downloadSfile(url, saveDir, options);
889
+ return { success: true, value: result };
890
+ }
891
+ catch (error) {
892
+ return { success: false, error: error };
893
+ }
894
+ }
895
+ function createDownloader(defaultOptions) {
896
+ const defaults = normalizeOptions(defaultOptions);
897
+ return {
898
+ async download(url, saveDir, callOptions) {
899
+ const merged = stripUndefined({ ...defaults, ...callOptions });
900
+ return downloadSfile(url, saveDir, merged);
901
+ },
902
+ async downloadSafe(url, saveDir, callOptions) {
903
+ const merged = stripUndefined({ ...defaults, ...callOptions });
904
+ return downloadSfileSafe(url, saveDir, merged);
905
+ },
906
+ withOptions(newDefaults) {
907
+ const merged = stripUndefined({ ...defaults, ...newDefaults });
908
+ return createDownloader(merged);
909
+ },
910
+ };
911
+ }
912
+
913
+ function isSuccess(result) {
914
+ return result.success === true;
915
+ }
916
+ function isFailure(result) {
917
+ return result.success === false;
918
+ }
919
+ function mapError(result, fn) {
920
+ if (!result.success) {
921
+ return { success: false, error: fn(result.error) };
377
922
  }
923
+ return result;
378
924
  }
379
925
 
380
926
  exports.AppError = AppError;
381
927
  exports.BrowserError = BrowserError;
928
+ exports.DEFAULTS = DEFAULTS;
382
929
  exports.FileError = FileError;
930
+ exports.InputValidator = InputValidator;
931
+ exports.Logger = Logger;
383
932
  exports.NetworkError = NetworkError;
384
933
  exports.ValidationError = ValidationError;
934
+ exports.calculateRetryDelay = calculateRetryDelay;
935
+ exports.createDownloader = createDownloader;
385
936
  exports.downloadSfile = downloadSfile;
937
+ exports.downloadSfileSafe = downloadSfileSafe;
938
+ exports.extractFilenameFromContentDisposition = extractFilenameFromContentDisposition;
939
+ exports.isAppError = isAppError;
940
+ exports.isErr = isFailure;
941
+ exports.isError = isError;
942
+ exports.isErrorWithCode = isErrorWithCode;
943
+ exports.isOk = isSuccess;
944
+ exports.isRetryableError = isRetryableError;
945
+ exports.mapErr = mapError;
946
+ exports.normalizeOptions = normalizeOptions;
947
+ exports.safeStringify = safeStringify;
948
+ exports.sanitizeFilename = sanitizeFilename;
949
+ exports.sleep = sleep;
386
950
  //# sourceMappingURL=lib.cjs.map