feat: Extend audit trail with tube received tracking and enhance PDF workflow

Major Updates:

1. Extended Audit Trail System
   - Added tube received events tracking from SP_TUBES table (TUBESTATUS=4)
   - New audit tab "Receive" in dialog_audit.php to display tube reception history
   - ApiRequestsAuditController now fetches and returns tube received events with:
     * Sample type, tube status, collection date, and user information
   - Audit events sorted chronologically combining validation, sampling, and receiving events

2. Enhanced PDF Generation Workflow
   - Created new PdfHelper library with methods for PDF generation and posting to spooler
   - Reports can now be generated via GET /report/{accessnumber}/pdf endpoint
   - Updated PDF spooler API endpoint from port 3030 to 3000
   - Added retry PDF button with spinner animation for failed generations
   - Fixed PDF status check to use correct spooler endpoint

3. Validation UI Improvements
   - Added toast notification showing PDF queued after second validation (val2)
   - Retry PDF button appears when val1 and val2 are complete
   - Toast notifications success/error states with auto-dismiss after 2 seconds
   - Loading state with spinning icon during PDF retry operation

4. Report Template Fixes
   - Fixed typo in Val2 By display (added missing ":")
   - Consistent formatting with Val1 By : and Val2 By :

5. Documentation Updates
   - TODO.md updated with:
     * Auto generate PDF (in progress)
     * Print Eng Result (pending)
     * Add Receive to Audit (completed)

6. Cleanup
   - Removed legacy Node.js spooler implementation (node_spooler directory)
   - Deleted P0_log.txt (SQL setup scripts no longer needed in repo)
   - Cleaned up .gitignore to remove stale node_spooler entries

Files Changed:
- app/Controllers/ApiRequestsAuditController.php (tube received audit)
- app/Controllers/ReportController.php (port update: 3030 → 3000)
- app/Libraries/PdfHelper.php (new library)
- app/Views/report/template.php (typo fix)
- app/Views/shared/content_requests.php (retry PDF button)
- app/Views/shared/dialog_audit.php (receive tab)
- app/Views/shared/script_requests.php (retry handler, tube events)
- app/Views/shared/script_validation.php (enhanced toast)
- TODO.md (pending/completed tasks)
- .gitignore (cleanup)
- Deleted: node_spooler/* (legacy implementation)
- Deleted: P0_log.txt (no longer needed)
This commit is contained in:
mahdahar 2026-02-04 11:09:42 +07:00
parent a9b387b21f
commit 0b4fdcfe5f
19 changed files with 165 additions and 2193 deletions

9
.gitignore vendored
View File

@ -126,11 +126,4 @@ _modules/*
/phpunit*.xml
.claude/
.serena/
#-------------------------
# PDF Spooler Data
#-------------------------
node_spooler/data/
node_spooler/node_modules/
node_spooler/logs/
.serena/

View File

@ -1,50 +0,0 @@
1. Buat Tabel POSITIONS untuk (Role User) (ROLE, ROLEID, DESCRIPTION)
CREATE TABLE [GDC_CMOD].[dbo].[ROLES] (
[ROLE] NVARCHAR(50) NOT NULL, -- Primary Key
[ROLEID] INT NOT NULL, -- Harus Unik
[DESCRIPTION] NVARCHAR(MAX) NULL,
-- Menetapkan Primary Key
CONSTRAINT PK_Roles PRIMARY KEY ([ROLE]),
-- Menetapkan Unique Constraint
CONSTRAINT UQ_RoleID UNIQUE ([ROLEID])
);
INSERT INTO [GDC_CMOD].[dbo].[ROLES] ([ROLE], [ROLEID], [DESCRIPTION])
VALUES
('Superuser', 0, 'All Access'),
('Admin', 1, 'Super user sistem, konfigurasi, manajemen user & data'),
('Analis LAB', 2, 'Validasi & pengolahan hasil laboratorium'),
('Phlebotomist', 3, 'Pengambilan dan pencatatan spesimen'),
('Customer Service', 4, 'Monitoring & pelayanan informasi pasien');
2. Hapus semua isi USERS lalu :
INSERT INTO USERS(USERID, [PASSWORD], USERROLEID, NAME)
VALUES
('LISFSE', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 0, 'SYSTEM'),
('ABB', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Ardea Bagus Bimantara, S.Tr.Kes'),
('AHT', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 1, 'dr. Arifoe Hajat, Sp.PK(K)'),
('ASW', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Asti Sri Wiyanti, A.Md.AK'),
('BYS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Betha Yogyanti Setyarini, A.Md.AK'),
('FKS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Fairushafa Khairunnisa Sasmita, S.Tr.Kes'),
('HAY', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Hewi Aryanti, A.Md.Kep'),
('KNS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Kartya Nur Sholihatul Umah, S.Kep.Ns'),
('LPS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Lintang Pramuli Suradi, S.S.T'),
('LDK', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Lidya Dwindana Kartikasari, A.Md.Kes'),
('RID', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 4, 'Rhemanda Ivena Dinta, A.Md.Bns'),
('MDW', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Margareta Dwi Widiani, A.Md.Kep'),
('MJS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Mentari Jaya Sari, A.Md.AK'),
('MRS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Maria Scholastica, A.Md.Kep'),
('AQP', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Anjani Okta Prastiwi, A.Md.Kep'),
('RSW', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 0, 'Ratna Setyowati, A.Md.T'),
('SAI', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 1, 'Sri Andayani, A.Md.Kes'),
('SFB', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Stevani Florentina Bihi, S.Pd., S.Tr.Kes'),
('VSO', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Veronica Sulistyo, A.Md.Kes'),
('YAA', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 2, 'Yulia Anita, A.Md.Kes'),
('SYA', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Sutyi Yuliyana, A.Md.Kep'),
('MTP', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 0, 'Muhammad Tegar Prasetya, S.Kom'),
('NRR', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 0, 'Nur Rizky Romadhon, M.Tr.Kom'),
('LAS', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 3, 'Luthfi Anindyani Sulistiono, S.Kep.Ns'),
('HSI', '$2y$10$qUKTBKk.gJsgIKKlNB5QwuJ4TFLBl6buARUjaY9eSSmdDX3EO/tSi', 1, 'Dr. dr. Hermi Suprati, M.Kes');

52
TODO.md
View File

@ -1,34 +1,34 @@
# Project Checklist: Glen RME & Lab Management System
**Last Updated:** 20260203
**Last Updated:** 20260204
Pending:
- Auto generate PDF
- Print Eng Result
- Reprint Label (Add functionality to reprint labels)
- Print Result Audit (Track when result reports are printed/exported, log user and timestamp)
Completed:
- 01 : Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
- 02 : Role-Based Dashboard Filtering (Filter by patient_status or service_type)
- 03 : Fix Table Sorting (Enable sorting by "No Register" and "Patient Name")
- 04 : Fix Language Toggle (Toggle lab result preview between Indonesian and English)
- 05 : Apply Row Color-Coding (Color-code "No Register" column)
- 06 : Initialize RME Sidebar Menu (Create menu items)
- 07 : Dashboard Performance (When getting data more than 100 rows, it load too slow)
- 08 : Dashboard for Lab -> no test with only number, remove request with empty test
- 09 : Dashboard for Others -> complete
- 10 : Refactor same views/*role* to views/shared
- 11 : Move all CDN to local
- 12 : Remove 'status' field on dashboard
- 13 : Restrict 'Validate' to Lab, Admin, Superuser
- 14 : Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions)
- 15 : Restrict 'UnValidate' to Admin, Superuser
- 16 : Remove 'UnCollect'
- 17 : Audit Trail (Track all actions: validation, unvalidation, collection, uncollection)
- 18 : Create Validate Page
- 19 : Sync color with old gdc_cmod
- 20 : Add Val1 Val2 on the result
- 21 : Show Print / PDF button when val1 val2 done
- 22 : Restrict Print/Save-to-PDF to CS Role only (Admin, Lab, CS can print/save)
Addition on dev :
- adding init-isDev on index.php to set default date on dev dashboard
- Update User Role levels (Standardize roles: Superuser, Admin, Lab, Phlebo, CS)
- Role-Based Dashboard Filtering (Filter by patient_status or service_type)
- Fix Table Sorting (Enable sorting by "No Register" and "Patient Name")
- Fix Language Toggle (Toggle lab result preview between Indonesian and English)
- Apply Row Color-Coding (Color-code "No Register" column)
- Initialize RME Sidebar Menu (Create menu items)
- Dashboard Performance (When getting data more than 100 rows, it load too slow)
- Dashboard for Lab -> no test with only number, remove request with empty test
- Dashboard for Others -> complete
- Refactor same views/*role* to views/shared
- Move all CDN to local
- Remove 'status' field on dashboard
- Restrict 'Validate' to Lab, Admin, Superuser
- Hide/Disable 'Validation' button after 2nd validation (Prevent redundant validation actions)
- Restrict 'UnValidate' to Admin, Superuser
- Remove 'UnCollect'
- Audit Trail (Track all actions: validation, unvalidation, collection, uncollection)
- Create Validate Page
- Sync color with old gdc_cmod
- Add Val1 Val2 on the result
- Show Print / PDF button when val1 val2 done
- Restrict Print/Save-to-PDF to CS Role only (Admin, Lab, CS can print/save)
- Add Receive to Audit

View File

@ -12,7 +12,8 @@ class ApiRequestsAuditController extends BaseController {
$result = [
'accessnumber' => $accessnumber,
'validation' => [],
'sample_collection' => []
'sample_collection' => [],
'tube_received' => []
];
$sqlAudit = "SELECT EVENT_TYPE, USERID, EVENT_AT, REASON
@ -47,6 +48,21 @@ class ApiRequestsAuditController extends BaseController {
];
}
$sqlSpTubes = "SELECT SAMPLETYPE, TUBESTATUS, COLLECTIONDATE, LOGUSERID
FROM glendb.dbo.SP_TUBES
WHERE SP_ACCESSNUMBER = ? AND TUBESTATUS = 4
ORDER BY COLLECTIONDATE ASC";
$spTubeRows = $db->query($sqlSpTubes, [$accessnumber])->getResultArray();
foreach ($spTubeRows as $row) {
$result['tube_received'][] = [
'sampletype' => trim($row['SAMPLETYPE']),
'tubestatus' => (int)$row['TUBESTATUS'],
'datetime' => $row['COLLECTIONDATE'] ? date('Y-m-d H:i:s', strtotime($row['COLLECTIONDATE'])) : null,
'user' => trim($row['LOGUSERID'])
];
}
return $this->respond(['status' => 'success', 'data' => $result]);
}
}

View File

@ -101,7 +101,7 @@ class ReportController extends BaseController
public function checkPdfStatus($jobId)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://glenlis:3030/api/pdf/status/$jobId");
curl_setopt($ch, CURLOPT_URL, "http://glenlis:3000/api/pdf/status/$jobId");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
@ -123,7 +123,7 @@ class ReportController extends BaseController
private function postToSpooler($html, $filename)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'http://glenlis:3030/api/pdf/generate');
curl_setopt($ch, CURLOPT_URL, 'http://glenlis:3000/api/pdf/generate');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'html' => $html,

View File

@ -0,0 +1,54 @@
<?php
namespace App\Libraries;
class PdfHelper
{
protected $db;
public function __construct($db)
{
$this->db = $db;
}
public function generatePdf(string $accessnumber, int $eng = 0): string
{
$reportHelper = new \App\Libraries\ReportHelper($this->db);
$data = $reportHelper->getReportData($accessnumber, $eng);
$data['eng'] = $eng;
$data['accessnumber'] = $accessnumber;
$data['ispdf'] = 1;
$html = view('report/template', $data);
$filename = $accessnumber . '.pdf';
return $this->postHtmlToSpooler($html, $filename);
}
public function postHtmlToSpooler(string $html, string $filename): string
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'http://glenlis:3000/api/pdf/generate');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'html' => $html,
'filename' => $filename
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
log_message('error', "Spooler API returned HTTP $httpCode");
throw new \Exception('Failed to queue PDF generation');
}
$data = json_decode($response, true);
return $data['jobId'];
}
}

View File

@ -84,7 +84,7 @@ $i = 1;
<?php endif; ?>
<pre class='small'>Collected on <?= esc($collData) ?>
Received on <?= esc($recvData) ?>
Val1 By : <?= esc($val1User) ?> | Val2 By <?= esc($val2User) ?>
Val1 By : <?= esc($val1User) ?> | Val2 By : <?= esc($val2User) ?>
Page <?= $i ?>/<?= $npage ?> Printed By : <?= esc($valBy) ?> <?= esc($date) ?></pre>
</td>
<td class='right'>

View File

@ -227,9 +227,17 @@
</td>
<td>
<template x-if="req.VAL1USER && req.VAL2USER">
<div>
<a :href="'<?=base_url('report/');?>' + req.SP_ACCESSNUMBER" target="_blank" class="btn btn-xs btn-outline btn-primary">Print</a>
<a :href="'<?=base_url('report/');?>' + req.SP_ACCESSNUMBER" target="_blank" class="btn btn-xs btn-outline btn-primary">PDF</a>
<div class='flex flex-col gap-1'>
<div class="flex gap-1">
<a :href="'<?=base_url('report/');?>' + req.SP_ACCESSNUMBER" target="_blank" class="btn btn-xs btn-outline btn-primary">Print</a>
<a :href="'<?=base_url('report/');?>' + req.SP_ACCESSNUMBER" target="_blank" class="btn btn-xs btn-outline btn-primary">PDF</a>
</div>
<button class="btn btn-xs btn-warning btn-outline"
@click="retryPdf(req.SP_ACCESSNUMBER)"
title="Retry PDF generation">
<i class="fa fa-sync-alt" :class="{ 'fa-spin': retryingPdf[req.SP_ACCESSNUMBER] }"></i>
<span x-text="retryingPdf[req.SP_ACCESSNUMBER] ? 'Retrying...' : 'Retry PDF'"></span>
</button>
</div>
</template>
</td>

View File

@ -20,6 +20,9 @@
<button @click="auditTab = 'validation'"
:class="auditTab === 'validation' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Validation</button>
<button @click="auditTab = 'receive'"
:class="auditTab === 'receive' ? 'btn-active btn-warning text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Receive</button>
<button @click="auditTab = 'sample'"
:class="auditTab === 'sample' ? 'btn-active btn-primary text-white' : 'btn-ghost'"
class="btn btn-sm join-item">Sample</button>
@ -44,16 +47,18 @@
<div class="relative border-l-2 border-base-300 ml-3 space-y-4">
<template x-for="event in getFilteredAuditEvents" :key="event.id">
<div class="ml-6 relative">
<div class="absolute -left-9 w-6 h-6 rounded-full flex items-center justify-center"
<div class="absolute -left-9 w-6 h-6 rounded-full flex items-center justify-center"
:class="{
'bg-success': event.category === 'validation' && event.type !== 'UNVAL',
'bg-info': event.category === 'sample',
'bg-warning': event.category === 'receive',
'bg-error': event.category === 'validation' && event.type === 'UNVAL'
}">
<i class="fa text-xs text-white"
:class="{
'fa-check': event.category === 'validation' && event.type !== 'UNVAL',
'fa-vial': event.category === 'sample',
'fa-check-circle': event.category === 'receive',
'fa-times': event.category === 'validation' && event.type === 'UNVAL'
}"></i>
</div>
@ -64,6 +69,7 @@
:class="{
'badge-success': event.category === 'validation' && event.type !== 'UNVAL',
'badge-info': event.category === 'sample',
'badge-warning': event.category === 'receive',
'badge-error': event.category === 'validation' && event.type === 'UNVAL'
}"
x-text="event.type"></span>

View File

@ -6,6 +6,7 @@ document.addEventListener('alpine:init', () => {
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
retryingPdf: {},
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-[#ff99aa] text-black font-bold',
@ -288,7 +289,19 @@ document.addEventListener('alpine:init', () => {
reason: null
});
});
this.auditData.tube_received?.forEach(t => {
events.push({
id: id++,
category: 'receive',
type: 'RECEIVED',
description: `${t.sampletype} received by ${t.user}`,
datetime: t.datetime,
user: t.user,
reason: null
});
});
return events.sort((a, b) => {
if (!a.datetime) return 1;
if (!b.datetime) return -1;
@ -299,5 +312,32 @@ document.addEventListener('alpine:init', () => {
if (this.auditTab === 'all') return this.getAllAuditEvents;
return this.getAllAuditEvents.filter(e => e.category === this.auditTab);
},
async retryPdf(accessnumber) {
if (this.retryingPdf[accessnumber]) return;
this.retryingPdf[accessnumber] = true;
try {
const res = await fetch(`${BASEURL}/report/${accessnumber}/pdf`);
const data = await res.json();
if (data.success) {
this.showToast('PDF queued for generation', 'success');
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
this.showToast('PDF generation failed - try again', 'error');
} finally {
this.retryingPdf[accessnumber] = false;
}
},
showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} fixed top-4 right-4 z-50`;
toast.innerHTML = `<i class="fa ${type === 'error' ? 'fa-times-circle' : 'fa-check-circle'}"></i> ${message}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
},
}));
});

View File

@ -179,20 +179,20 @@ document.addEventListener('alpine:init', () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userid: `${userid}` })
}).then(response => response.json()).then(data => {
// Show toast
this.showToast(`Validated: ${accessnumber}`);
if (data.val === 2) {
this.showToast(`Validated (val2): ${accessnumber} - PDF queued`);
} else {
this.showToast(`Validated: ${accessnumber}`);
}
// Remove validated item from local list
this.unvalidatedList = this.unvalidatedList.filter(
item => item.SP_ACCESSNUMBER !== accessnumber
);
// Auto-advance to next request
const filteredLength = this.unvalidatedFiltered.length;
if (filteredLength > 0) {
const nextIndex = Math.min(this.currentIndex, filteredLength - 1);
this.closeValDialog();
// Use setTimeout for reliable focus after dialog re-renders
setTimeout(() => this.openValDialogByIndex(nextIndex), 50);
} else {
this.closeValDialog();

View File

@ -1,308 +0,0 @@
# PDF Spooler v2.0
Bismillahirohmanirohim.
## Overview
Node.js Express service with internal queue for HTML to PDF conversion using Chrome DevTools Protocol.
## Architecture
```
Client Application
↓ POST {html, filename}
Node.js Spooler (port 3030)
↓ queue
Internal Queue (max 5 concurrent)
↓ process
PDF Generator (Chrome CDP port 42020)
↓ save
data/pdfs/{filename}.pdf
```
## Features
- HTTP API for PDF generation (no file watching)
- Internal queue with max 5 concurrent processing
- Max 100 jobs in queue
- In-memory job tracking (auto-cleanup after 60 min)
- Chrome crash detection & restart (max 3 attempts)
- Comprehensive logging (info, error, metrics)
- Automated cleanup with dry-run mode
- Admin dashboard for monitoring
- Manual error review required (see `data/error/`)
## API Endpoints
### POST /api/pdf/generate
Generate PDF from HTML content.
**Request:**
```json
{
"html": "<html>...</html>",
"filename": "1234567890.pdf"
}
```
**Response (Success):**
```json
{
"success": true,
"jobId": "job_1738603845123_abc123xyz",
"status": "queued",
"message": "Job added to queue"
}
```
**Response (Error):**
```json
{
"success": false,
"error": "Queue is full, please try again later"
}
```
### GET /api/pdf/status/:jobId
Check job status.
**Response (Queued/Processing):**
```json
{
"success": true,
"jobId": "job_1738603845123_abc123xyz",
"status": "queued|processing",
"progress": 0|50,
"pdfUrl": null,
"error": null
}
```
**Response (Completed):**
```json
{
"success": true,
"jobId": "job_1738603845123_abc123xyz",
"status": "completed",
"progress": 100,
"pdfUrl": "/node_spooler/data/pdfs/1234567890.pdf",
"error": null
}
```
**Response (Error):**
```json
{
"success": true,
"jobId": "job_1738603845123_abc123xyz",
"status": "error",
"progress": 0,
"pdfUrl": null,
"error": "Chrome timeout"
}
```
### GET /api/queue/stats
Queue statistics.
```json
{
"success": true,
"queueSize": 12,
"processing": 3,
"completed": 45,
"errors": 2,
"avgProcessingTime": 0.82,
"maxQueueSize": 100
}
```
## Error Handling
### Chrome Crash Handling
1. Chrome crash detected (CDP connection lost or timeout)
2. Stop processing current jobs
3. Move queue jobs back to "queued" status
4. Attempt to restart Chrome (max 3 attempts)
5. Resume processing
### Failed Jobs
- Failed jobs logged to `data/error/{jobId}.json`
- Never auto-deleted (manual review required)
- Review `logs/errors.log` for details
- Error JSON contains full job details including error message
## Cleanup
### Manual Execution
```bash
# Test cleanup (dry-run)
npm run cleanup:dry-run
# Execute cleanup
npm run cleanup
```
### Retention Policy
| Directory | Retention | Action |
|-----------|-----------|---------|
| `data/pdfs/` | 7 days | Move to archive |
| `data/archive/YYYYMM/` | 45 days | Delete |
| `data/error/` | Manual | Never delete |
| `logs/` | 30 days | Delete (compress after 7 days) |
### Cleanup Tasks
1. Archive PDFs older than 7 days to `data/archive/YYYYMM/`
2. Delete archived PDFs older than 45 days
3. Compress log files older than 7 days
4. Delete log files older than 30 days
5. Check disk space (alert if > 80%)
## Monitoring
### Admin Dashboard
Open `admin.html` in browser for:
- Real-time queue statistics
- Processing metrics
- Error file list
- Disk space visualization
**URL:** `http://localhost:3030/admin.html`
### Key Metrics
- Average PDF time: < 2 seconds
- Success rate: > 95%
- Queue size: < 100 jobs
- Disk usage: < 80%
### Log Files
- `logs/spooler.log` - All API events (info, warn, error)
- `logs/errors.log` - PDF generation errors only
- `logs/metrics.log` - Performance stats (per job)
- `logs/cleanup.log` - Cleanup execution logs
## Troubleshooting
### Spooler Not Starting
- Check if Chrome is running on port 42020
- Check logs: `logs/spooler.log`
- Verify directories exist: `data/pdfs`, `data/archive`, `data/error`, `logs`
- Check Node.js version: `node --version` (need 14+)
- Verify dependencies installed: `npm install`
**Start Chrome manually:**
```bash
"C:/Program Files/Google/Chrome/Application/chrome.exe"
--headless
--disable-gpu
--remote-debugging-port=42020
```
### PDF Not Generated
- Check job status via API: `GET /api/pdf/status/{jobId}`
- Review error logs: `logs/errors.log`
- Verify Chrome connection: Check logs for CDP connection errors
- Check HTML content: Ensure valid HTML
### Queue Full
- Wait for current jobs to complete
- Check admin dashboard for queue size
- Increase `maxQueueSize` in `spooler.js` (default: 100)
- Check if jobs are stuck (processing too long)
### Chrome Crashes Repeatedly
- Check system RAM (need minimum 2GB available)
- Reduce `maxConcurrent` in `spooler.js` (default: 5)
- Check for memory leaks in Chrome
- Restart Chrome manually and monitor
- Check system resources: Task Manager > Performance
### High Disk Usage
- Run cleanup: `npm run cleanup`
- Check `data/archive/` for old folders
- Check `logs/` for old logs
- Check `data/pdfs/` for large files
- Consider reducing PDF retention time in `cleanup-config.json`
## Deployment
### Quick Start
```bash
# 1. Create directories
cd node_spooler
mkdir -p logs data/pdfs data/archive data/error
# 2. Install dependencies
npm install
# 3. Start Chrome (if not running)
"C:/Program Files/Google/Chrome/Application/chrome.exe"
--headless
--disable-gpu
--remote-debugging-port=42020
# 4. Start spooler
npm start
# 5. Test API
curl -X POST http://localhost:3030/api/pdf/generate \
-H "Content-Type: application/json" \
-d "{\"html\":\"<html><body>Test</body></html>\",\"filename\":\"test.pdf\"}"
# 6. Open admin dashboard
# http://localhost:3030/admin.html
```
### Production Setup
**1. Create batch file wrapper:**
```batch
@echo off
cd /d D:\data\www\gdc_cmod\node_spooler
C:\node\node.exe spooler.js
```
**2. Create Windows service:**
```batch
sc create PDFSpooler binPath= "D:\data\www\gdc_cmod\node_spooler\spooler-start.bat" start=auto
sc start PDFSpooler
```
**3. Create scheduled task for cleanup:**
```batch
schtasks /create /tn "PDF Cleanup Daily" /tr "C:\node\node.exe D:\data\www\gdc_cmod\node_spooler\cleanup.js" /sc daily /st 01:00
schtasks /create /tn "PDF Cleanup Weekly" /tr "C:\node\node.exe D:\data\www\gdc_cmod\node_spooler\cleanup.js weekly" /sc weekly /d MON /st 01:00
```
## Version History
- **2.0.0 (2025-02-03):** Migrated from file watching to HTTP API queue
- Removed file watching (chokidar)
- Added Express HTTP API
- Internal queue with max 5 concurrent
- Max 100 jobs in queue
- Job auto-cleanup after 60 minutes
- Enhanced error handling with Chrome restart
- Admin dashboard for monitoring
- Automated cleanup system
## License
Internal use only.

View File

@ -1,339 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Spooler Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 30px;
font-size: 28px;
}
h2 {
color: #555;
margin-top: 30px;
margin-bottom: 15px;
font-size: 22px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
border: 1px solid #e0e0e0;
padding: 20px;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-number {
font-size: 36px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 1px;
}
.processing-time {
margin-bottom: 30px;
font-size: 18px;
color: #555;
}
.processing-time span {
font-weight: bold;
color: #667eea;
}
button {
padding: 12px 24px;
margin: 5px;
cursor: pointer;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-dry-run {
background: #ff9800;
color: white;
}
.btn-execute {
background: #4caf50;
color: white;
}
.btn-refresh {
background: #2196f3;
color: white;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
button:active {
transform: translateY(0);
}
.disk {
margin-top: 30px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.disk-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
color: #666;
}
.disk-bar {
width: 100%;
height: 30px;
background: #e0e0e0;
border-radius: 15px;
overflow: hidden;
position: relative;
}
.disk-used {
height: 100%;
background: linear-gradient(90deg, #4caf50 0%, #8bc34a 100%);
transition: width 0.5s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.disk-warning .disk-used {
background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%);
}
.disk-critical .disk-used {
background: linear-gradient(90deg, #f44336 0%, #ef5350 100%);
}
.errors {
margin-top: 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
th, td {
border: 1px solid #e0e0e0;
padding: 12px;
text-align: left;
}
th {
background: #f5f5f5;
font-weight: 600;
color: #555;
text-transform: uppercase;
font-size: 12px;
}
tr:hover td {
background: #f9f9f9;
}
.no-errors {
text-align: center;
padding: 40px;
color: #4caf50;
font-size: 16px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<h1>PDF Spooler Admin Dashboard</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-number" id="queueSize">-</div>
<div class="stat-label">Queue Size</div>
</div>
<div class="stat-card">
<div class="stat-number" id="processing">-</div>
<div class="stat-label">Processing</div>
</div>
<div class="stat-card">
<div class="stat-number" id="completed">-</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-number" id="errors">-</div>
<div class="stat-label">Errors</div>
</div>
</div>
<div class="processing-time">
Average Processing Time: <span id="avgTime">-</span> seconds
</div>
<div class="disk">
<h2>Disk Space</h2>
<div class="disk-info">
<span id="diskTotal">-</span>
<span id="diskUsed">-</span>
</div>
<div class="disk-info">
<span>Free:</span>
<span id="diskFree">-</span>
</div>
<div class="disk-bar" id="diskBar">
<div class="disk-used" id="diskUsedBar" style="width: 0%">0%</div>
</div>
<div class="disk-info" style="margin-top: 10px;">
<span>Queue Limit:</span>
<span id="queueLimit">-</span>
</div>
</div>
<div style="margin-top: 30px;">
<button class="btn-dry-run" onclick="cleanup(true)">Dry-Run Cleanup</button>
<button class="btn-execute" onclick="cleanup(false)">Execute Cleanup</button>
<button class="btn-refresh" onclick="refreshStats()">Refresh Stats</button>
</div>
<div class="errors">
<h2>Recent Errors</h2>
<div id="errorContent">
<div class="loading">Loading errors...</div>
</div>
</div>
</div>
<script>
let refreshInterval;
async function refreshStats() {
try {
const response = await fetch('/api/queue/stats');
const data = await response.json();
document.getElementById('queueSize').textContent = data.queueSize;
document.getElementById('processing').textContent = data.processing;
document.getElementById('completed').textContent = data.completed;
document.getElementById('errors').textContent = data.errors;
document.getElementById('avgTime').textContent = data.avgProcessingTime ? data.avgProcessingTime.toFixed(2) : '-';
document.getElementById('queueLimit').textContent = `${data.queueSize} / ${data.maxQueueSize}`;
updateDiskInfo();
if (data.errors > 0) {
await loadErrors();
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
async function loadErrors() {
try {
const response = await fetch('/data/error/');
const data = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(data, 'text/html');
const links = Array.from(doc.querySelectorAll('a')).filter(a => a.textContent.endsWith('.json'));
if (links.length === 0) {
document.getElementById('errorContent').innerHTML = '<div class="no-errors">No errors found</div>';
return;
}
let html = '<table><thead><tr><th>Job ID</th><th>Error</th><th>Time</th></tr></thead><tbody>';
links.forEach(link => {
const filename = link.textContent;
const jobId = filename.replace('.json', '');
const time = filename.split('_')[1];
html += `<tr>
<td>${jobId}</td>
<td><a href="/data/error/${filename}" target="_blank">View Details</a></td>
<td>${new Date(parseInt(time)).toLocaleString()}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('errorContent').innerHTML = html;
} catch (error) {
console.error('Failed to load errors:', error);
}
}
async function updateDiskInfo() {
try {
const stats = await fetch('/api/disk-space');
const data = await stats.json();
document.getElementById('diskTotal').textContent = 'Total: ' + data.total;
document.getElementById('diskUsed').textContent = 'Used: ' + data.used;
document.getElementById('diskFree').textContent = data.free;
const percentage = parseFloat(data.percentage);
const diskBar = document.getElementById('diskBar');
const diskUsedBar = document.getElementById('diskUsedBar');
diskUsedBar.textContent = percentage.toFixed(1) + '%';
diskUsedBar.style.width = percentage + '%';
diskBar.classList.remove('disk-warning', 'disk-critical');
if (percentage > 90) {
diskBar.classList.add('disk-critical');
} else if (percentage > 80) {
diskBar.classList.add('disk-warning');
}
} catch (error) {
console.error('Failed to fetch disk space:', error);
}
}
async function cleanup(dryRun) {
const mode = dryRun ? 'dry-run' : 'execute';
if (!confirm(`Run cleanup in ${mode} mode?`)) return;
try {
alert('Cleanup completed. Check logs for details.');
await refreshStats();
} catch (error) {
alert('Cleanup failed: ' + error.message);
}
}
document.addEventListener('DOMContentLoaded', () => {
refreshStats();
refreshInterval = setInterval(refreshStats, 5000);
});
</script>
</body>
</html>

View File

@ -1,14 +0,0 @@
{
"pdfRetentionDays": 7,
"archiveRetentionDays": 45,
"logCompressDays": 7,
"logDeleteDays": 30,
"diskUsageThreshold": 80,
"directories": {
"pdfs": "./data/pdfs",
"archive": "./data/archive",
"error": "./data/error",
"logs": "./logs"
},
"note": "data/error/ never auto-deleted - manual review required by engineers"
}

View File

@ -1,208 +0,0 @@
const fs = require('fs');
const path = require('path');
const LOGS_DIR = path.join(__dirname, 'logs');
const LOG_FILE = path.join(LOGS_DIR, 'cleanup.log');
const CONFIG = {
PDF_DIR: path.join(__dirname, 'data/pdfs'),
ARCHIVE_DIR: path.join(__dirname, 'data/archive'),
ERROR_DIR: path.join(__dirname, 'data/error'),
PDF_RETENTION_DAYS: 7,
ARCHIVE_RETENTION_DAYS: 45,
LOG_COMPRESS_DAYS: 7,
LOG_DELETE_DAYS: 30,
DISK_USAGE_THRESHOLD: 80
};
function logInfo(message, data = null) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [INFO] ${message}${data ? ' ' + JSON.stringify(data) : ''}\n`;
fs.appendFileSync(LOG_FILE, logEntry);
console.log(`[INFO] ${message}`, data || '');
}
function logWarn(message, data = null) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [WARN] ${message}${data ? ' ' + JSON.stringify(data) : ''}\n`;
fs.appendFileSync(LOG_FILE, logEntry);
console.warn(`[WARN] ${message}`, data || '');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async function runCleanup(dryRun = false) {
logInfo('Cleanup started', { dryRun });
const startTime = Date.now();
try {
await archiveOldPDFs(dryRun);
await deleteOldArchives(dryRun);
await rotateLogs(dryRun);
const diskInfo = await checkDiskSpace();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logInfo('Cleanup completed', { elapsedSeconds: elapsed, diskInfo });
console.log('\nCleanup completed in', elapsed + 's');
console.log('Disk space:', diskInfo.total, 'total,', diskInfo.used, 'used,', diskInfo.free, 'free');
console.log('Disk usage:', diskInfo.percentage + '%');
if (diskInfo.percentage > CONFIG.DISK_USAGE_THRESHOLD) {
logWarn('Disk usage above threshold', { current: diskInfo.percentage, threshold: CONFIG.DISK_USAGE_THRESHOLD });
console.warn('WARNING: Disk usage above', CONFIG.DISK_USAGE_THRESHOLD + '%');
}
} catch (error) {
logError('Cleanup failed', error);
console.error('Cleanup failed:', error.message);
process.exit(1);
}
}
async function archiveOldPDFs(dryRun) {
logInfo('Archiving old PDFs...');
const files = fs.readdirSync(CONFIG.PDF_DIR);
const now = Date.now();
const oneDayMs = 24 * 60 * 60 * 1000;
const sevenDaysMs = 7 * oneDayMs;
let archivedCount = 0;
files.forEach(file => {
if (!file.endsWith('.pdf')) return;
const filePath = path.join(CONFIG.PDF_DIR, file);
const stats = fs.statSync(filePath);
const age = now - stats.mtimeMs;
if (age > sevenDaysMs) {
const month = new Date(stats.mtimeMs).toISOString().slice(0, 7);
const archivePath = path.join(CONFIG.ARCHIVE_DIR, month);
if (!fs.existsSync(archivePath)) {
fs.mkdirSync(archivePath, { recursive: true });
}
if (!dryRun) {
fs.renameSync(filePath, path.join(archivePath, file));
archivedCount++;
logInfo('Archived PDF', { file, month });
} else {
logInfo('[DRY-RUN] Would archive', { file, month });
}
}
});
logInfo('Archived PDFs', { count: archivedCount, dryRun });
}
async function deleteOldArchives(dryRun) {
logInfo('Deleting old archives...');
const now = Date.now();
const fortyFiveDaysMs = 45 * 24 * 60 * 60 * 1000;
const months = fs.readdirSync(CONFIG.ARCHIVE_DIR);
let deletedCount = 0;
months.forEach(month => {
const monthPath = path.join(CONFIG.ARCHIVE_DIR, month);
const stats = fs.statSync(monthPath);
const age = now - stats.mtimeMs;
if (age > fortyFiveDaysMs) {
if (!dryRun) {
fs.rmSync(monthPath, { recursive: true, force: true });
deletedCount++;
logInfo('Deleted old archive', { month });
} else {
logInfo('[DRY-RUN] Would delete archive', { month });
}
}
});
logInfo('Deleted old archives', { count: deletedCount, dryRun });
}
async function rotateLogs(dryRun) {
logInfo('Rotating logs...');
const files = fs.readdirSync(LOGS_DIR);
const now = Date.now();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
let compressedCount = 0;
let deletedCount = 0;
files.forEach(file => {
const filePath = path.join(LOGS_DIR, file);
const stats = fs.statSync(filePath);
const age = now - stats.mtimeMs;
if (age > sevenDaysMs && !file.endsWith('.gz')) {
if (!dryRun) {
try {
fs.copyFileSync(filePath, filePath + '.gz');
fs.unlinkSync(filePath);
compressedCount++;
logInfo('Compressed log', { file });
} catch (error) {
logWarn('Failed to compress log', { file, error: error.message });
}
} else {
logInfo('[DRY-RUN] Would compress', { file });
}
}
if (age > thirtyDaysMs) {
if (!dryRun) {
fs.unlinkSync(filePath);
deletedCount++;
logInfo('Deleted old log', { file });
} else {
logInfo('[DRY-RUN] Would delete', { file });
}
}
});
logInfo('Rotated logs', { compressed: compressedCount, deleted: deletedCount, dryRun });
}
async function checkDiskSpace() {
try {
const stats = fs.statfsSync(CONFIG.PDF_DIR);
const total = stats.bavail * stats.frsize;
const free = stats.bfree * stats.frsize;
const used = total - free;
const usedPercent = (used / total) * 100;
return {
total: formatBytes(total),
used: formatBytes(used),
free: formatBytes(free),
percentage: usedPercent.toFixed(1)
};
} catch (error) {
logError('Failed to check disk space', error);
return {
total: 'Unknown',
used: 'Unknown',
free: 'Unknown',
percentage: 0
};
}
}
const dryRun = process.argv.includes('--dry-run');
runCleanup(dryRun);

View File

@ -1,869 +0,0 @@
{
"name": "gdc-pdf-spooler",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gdc-pdf-spooler",
"version": "2.0.0",
"dependencies": {
"body-parser": "^1.20.2",
"chrome-remote-interface": "^0.30.0",
"express": "^4.18.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chrome-remote-interface": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.30.1.tgz",
"integrity": "sha512-emKaqCjYAgrT35nm6PvTUKJ++2NX9qAmrcNRPRGyryG9Kc7wlkvO0bmvEdNMrr8Bih2e149WctJZFzUiM1UNwg==",
"license": "MIT",
"dependencies": {
"commander": "2.11.x",
"ws": "^7.2.0"
},
"bin": {
"chrome-remote-interface": "bin/client.js"
}
},
"node_modules/commander": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
"integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -1,16 +0,0 @@
{
"name": "gdc-pdf-spooler",
"version": "2.0.0",
"description": "Express-based PDF spooler with Chrome CDP and internal queue",
"main": "spooler.js",
"scripts": {
"start": "node spooler.js",
"cleanup": "node cleanup.js",
"cleanup:dry-run": "node cleanup.js --dry-run"
},
"dependencies": {
"express": "^4.18.2",
"chrome-remote-interface": "^0.30.0",
"body-parser": "^1.20.2"
}
}

View File

@ -1,330 +0,0 @@
const express = require('express');
const bodyParser = require('body-parser');
const CRI = require('chrome-remote-interface');
const fs = require('fs');
const path = require('path');
const LOGS_DIR = path.join(__dirname, 'logs');
const LOG_FILE = path.join(LOGS_DIR, 'spooler.log');
const ERROR_LOG_FILE = path.join(LOGS_DIR, 'errors.log');
const METRICS_LOG_FILE = path.join(LOGS_DIR, 'metrics.log');
const CONFIG = {
port: 3030,
chromePort: 42020,
maxConcurrent: 5,
maxQueueSize: 100,
jobCleanupMinutes: 60,
jobRetentionMs: 60 * 60 * 1000
};
function logInfo(message, data = null) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [INFO] ${message}${data ? ' ' + JSON.stringify(data) : ''}\n`;
fs.appendFileSync(LOG_FILE, logEntry);
console.log(`[INFO] ${message}`, data || '');
}
function logWarn(message, data = null) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [WARN] ${message}${data ? ' ' + JSON.stringify(data) : ''}\n`;
fs.appendFileSync(LOG_FILE, logEntry);
console.warn(`[WARN] ${message}`, data || '');
}
function logError(message, error = null) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] [ERROR] ${message}${error ? ' ' + error.message + '\n' + error.stack : ''}\n`;
fs.appendFileSync(ERROR_LOG_FILE, logEntry);
fs.appendFileSync(LOG_FILE, logEntry);
console.error(`[ERROR] ${message}`, error || '');
}
class PDFQueue {
constructor() {
this.queue = [];
this.processing = new Set();
this.jobs = new Map();
this.chrome = null;
this.connected = false;
this.cleanupInterval = null;
}
async initialize() {
try {
//this.chrome = await CRI({ port: CONFIG.chromePort });
this.chrome = await CRI({ port: CONFIG.chromePort, host: '127.0.0.1' });
this.connected = true;
logInfo('Chrome CDP connected', { port: CONFIG.chromePort });
} catch (error) {
this.connected = false;
logError('Chrome CDP connection failed', error);
throw error;
}
this.startCleanup();
}
addJob(html, filename) {
if (this.queue.length >= CONFIG.maxQueueSize) {
logError('Queue full', { size: this.queue.length, max: CONFIG.maxQueueSize });
throw new Error('Queue is full, please try again later');
}
const jobId = `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const job = {
id: jobId,
html,
filename: filename || `${jobId}.pdf`,
status: 'queued',
createdAt: Date.now(),
startedAt: null,
completedAt: null,
processingTime: null,
error: null,
pdfUrl: null
};
this.queue.push(job);
this.jobs.set(jobId, job);
this.processQueue();
return job;
}
async processQueue() {
while (this.processing.size < CONFIG.maxConcurrent && this.queue.length > 0) {
const job = this.queue.shift();
this.processJob(job);
}
}
async processJob(job) {
this.processing.add(job.id);
job.status = 'processing';
job.startedAt = Date.now();
try {
if (!this.connected) {
await this.initialize();
}
const { Page } = this.chrome;
await Page.enable();
await Page.setContent(job.html);
const pdf = await Page.printToPDF({
format: 'A4',
printBackground: true,
margin: { top: 0, bottom: 0, left: 0, right: 0 }
});
const outputPath = path.join(__dirname, 'data/pdfs', job.filename);
fs.writeFileSync(outputPath, Buffer.from(pdf.data, 'base64'));
job.status = 'completed';
job.completedAt = Date.now();
job.processingTime = (job.completedAt - job.startedAt) / 1000;
job.pdfUrl = `/node_spooler/data/pdfs/${job.filename}`;
logInfo('PDF generated successfully', {
jobId: job.id,
filename: job.filename,
processingTime: job.processingTime
});
this.logMetrics(job);
} catch (error) {
job.status = 'error';
job.error = error.message;
job.completedAt = Date.now();
const errorPath = path.join(__dirname, 'data/error', `${job.id}.json`);
fs.writeFileSync(errorPath, JSON.stringify(job, null, 2));
logError('PDF generation failed', {
jobId: job.id,
filename: job.filename,
error: error.message
});
if (error.message.includes('Chrome') || error.message.includes('CDP')) {
await this.handleChromeCrash();
}
}
this.processing.delete(job.id);
this.processQueue();
}
async handleChromeCrash() {
logWarn('Chrome crashed, attempting restart...');
this.queue.forEach(job => job.status = 'queued');
this.processing.clear();
this.connected = false;
for (let i = 0; i < 3; i++) {
try {
await this.initialize();
logInfo('Chrome restarted successfully');
return;
} catch (error) {
logError(`Chrome restart attempt ${i + 1} failed`, error);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
logError('Chrome restart failed after 3 attempts');
}
startCleanup() {
this.cleanupInterval = setInterval(() => {
this.cleanupOldJobs();
}, CONFIG.jobCleanupMinutes * 60 * 1000);
}
cleanupOldJobs() {
const now = Date.now();
const jobsToDelete = [];
for (const [jobId, job] of this.jobs) {
if (job.status === 'completed' || job.status === 'error') {
const age = now - job.completedAt;
if (age > CONFIG.jobRetentionMs) {
jobsToDelete.push(jobId);
}
}
}
jobsToDelete.forEach(jobId => {
this.jobs.delete(jobId);
});
if (jobsToDelete.length > 0) {
logInfo('Cleaned up old jobs', { count: jobsToDelete.length });
}
}
logMetrics(job) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${job.id} status=${job.status} time=${job.processingTime}s filename=${job.filename}\n`;
fs.appendFileSync(METRICS_LOG_FILE, logEntry);
}
getJob(jobId) {
return this.jobs.get(jobId);
}
getStats() {
const allJobs = Array.from(this.jobs.values());
const completedJobs = allJobs.filter(j => j.status === 'completed');
const errorJobs = allJobs.filter(j => j.status === 'error');
const avgTime = completedJobs.length > 0
? completedJobs.reduce((sum, j) => sum + j.processingTime, 0) / completedJobs.length
: 0;
return {
queueSize: this.queue.length,
processing: this.processing.size,
completed: completedJobs.length,
errors: errorJobs.length,
avgProcessingTime: avgTime,
maxQueueSize: CONFIG.maxQueueSize
};
}
}
const app = express();
app.use(bodyParser.json());
app.use('/node_spooler/data', express.static(path.join(__dirname, 'data')));
const queue = new PDFQueue();
async function startServer() {
try {
await queue.initialize();
} catch (error) {
logError('Failed to connect to Chrome', error);
console.error('Please start Chrome with: "C:/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --remote-debugging-port=42020');
process.exit(1);
}
app.post('/api/pdf/generate', async (req, res) => {
try {
const { html, filename } = req.body;
if (!html) {
return res.status(400).json({
success: false,
error: 'HTML content is required'
});
}
const job = queue.addJob(html, filename);
res.json({
success: true,
jobId: job.id,
status: job.status,
message: 'Job added to queue'
});
} catch (error) {
logError('API error', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
app.get('/api/pdf/status/:jobId', (req, res) => {
const { jobId } = req.params;
const job = queue.getJob(jobId);
if (!job) {
return res.status(404).json({
success: false,
error: 'Job not found'
});
}
res.json({
success: true,
jobId: job.id,
status: job.status,
progress: job.status === 'completed' ? 100 : (job.status === 'processing' ? 50 : 0),
pdfUrl: job.pdfUrl,
error: job.error
});
});
app.get('/api/queue/stats', (req, res) => {
const stats = queue.getStats();
res.json({
success: true,
...stats
});
});
app.get('/api/cleanup', (req, res) => {
res.json({
success: true,
message: 'Please run cleanup manually: npm run cleanup'
});
});
app.listen(CONFIG.port, 'localhost', () => {
logInfo(`PDF Spooler started on port ${CONFIG.port}`);
});
}
startServer().catch(error => {
logError('Server startup failed', error);
process.exit(1);
});

View File

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>403 Forbidden</title>
</head>
<body>
<p>Directory access is forbidden.</p>
</body>
</html>