-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathserver.js
More file actions
292 lines (268 loc) · 11.8 KB
/
Copy pathserver.js
File metadata and controls
292 lines (268 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
#!/usr/bin/env node
// Reference HTTP server for running AWS Textract from the backend of a scribe.js
// browser application. The point of routing OCR through your own server,
// rather than calling Textract directly from the browser,
// is that AWS credentials stay on the server and never reach the user.
//
// How it works:
// - The browser POSTs a PDF to `POST /ocr` (Content-Type: application/pdf).
// - The server runs scribe.js end-to-end on that PDF:
// it sends each page to Textract, converts the response into scribe.js's
// OcrPage data model, and writes one NDJSON line per page to the response stream.
// - The browser-side demo at `client/demo.js`
// reads that NDJSON and calls `doc.insertParsedPage(...)` for each entry.
// Conversion has already happened on the server,
// so the browser just installs the structured pages.
//
// Each NDJSON line for a successful page contains the parsed `OcrPage`,
// the per-page layout `dataTables`, and a `warn` object for any conversion warning.
// Failed pages send `{ pageNum, error: { message } }` instead.
//
// Client disconnects (refresh, tab close, explicit `AbortController.abort` on the client)
// are detected via `res.on('close')` and propagated into `doc.recognize()` through an
// `AbortSignal`. An abandoned upload stops billing AWS as soon as the in-flight page
// settles, rather than running to completion.
//
// Usage:
// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
// TEXTRACT_REGIONS=us-east-1,us-west-2 \
// node server.js
//
// See README.md for environment variables and deployment notes.
//
// Concurrency: this example serves concurrent requests in a single Node process.
// Each request gets its own `ScribeDoc` with its own `progressHandler`,
// `warningHandler`, and `errorHandler`,
// so two uploads in flight at the same time stream independently without cross-talk.
// For horizontal scaling, run N copies behind a load balancer (e.g. via `node:cluster`).
import http from 'node:http';
import crypto from 'node:crypto';
import scribe from '../../scribe.js';
import { RecognitionModelTextract } from '../../cloud-adapters/aws-textract/RecognitionModelAwsTextract.js';
// Crash visibility: surface unhandled errors instead of silently dying.
/** @param {any} err */
const formatErr = (err) => (err && (err.stack || err.message || String(err))) || String(err);
process.on('unhandledRejection', (err) => {
console.error('[fatal] unhandledRejection:', formatErr(err));
});
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException:', formatErr(err));
});
const PORT = Number(process.env.PORT || 3000);
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const TEXTRACT_REGIONS = (process.env.TEXTRACT_REGIONS || process.env.AWS_REGION || 'us-east-1')
.split(',').map((r) => r.trim()).filter(Boolean);
const ANALYZE_LAYOUT = process.env.TEXTRACT_LAYOUT === '1' || process.env.TEXTRACT_LAYOUT === 'true';
const ANALYZE_TABLES = process.env.TEXTRACT_TABLES === '1' || process.env.TEXTRACT_TABLES === 'true';
const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 100 * 1024 * 1024);
const ENGINE_NAME = RecognitionModelTextract.config.name;
const readBody = (req) => new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on('data', (chunk) => {
total += chunk.length;
if (total > MAX_UPLOAD_BYTES) {
reject(Object.assign(new Error('Upload exceeds MAX_UPLOAD_BYTES'), { statusCode: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
const writeCORS = (res) => {
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
const handleOCR = async (req, res) => {
const reqId = crypto.randomBytes(3).toString('hex');
const t0 = Date.now();
const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(2)}s`;
/** @param {...any} parts */
const log = (...parts) => console.log(`[${reqId}] +${elapsed()}`, ...parts);
log(`POST ${req.url} from ${req.socket.remoteAddress || '?'}`);
// Client-disconnect detection.
// When the browser drops TCP (refresh, close tab, navigate away, explicit
// AbortController.abort on the client), propagate the abort into doc.recognize()
// so it stops scheduling new pages instead of running the rest of the upload.
//
// Use `res.on('close')` gated on `res.writableFinished`. Node also fires a 'close'
// event on `req`, but in modern Node that fires when the readable side completes
// (i.e. right after `req.on('end')`), NOT when the client goes away — so using it
// would abort every normal request the instant the body finishes uploading. The
// `res` stream's close event is the authoritative "the socket went away" signal,
// and `writableFinished` distinguishes "we already sent the full response" from
// "we were still writing when the client vanished".
const ac = new AbortController();
res.on('close', () => {
if (!res.writableFinished) {
log('client disconnected, aborting');
ac.abort(Object.assign(new Error('Client disconnected'), { name: 'AbortError' }));
}
});
const body = await readBody(req);
if (body.length === 0) {
log('empty body, replying 400');
res.statusCode = 400;
res.end('Empty body. POST a PDF as application/pdf.');
return;
}
log(`PDF received: ${(body.length / 1024 / 1024).toFixed(2)} MB`);
// scribe.openDocument accepts ArrayBuffer in its sorted-input form. Copy out of the
// Node Buffer's underlying pool so we hand over an exact, standalone ArrayBuffer.
const pdfArrayBuffer = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
if (ac.signal.aborted) {
log('aborted before opening document, skipping recognition');
return;
}
res.statusCode = 200;
writeCORS(res);
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-store');
// Disable proxy buffering so the browser sees lines as they're written.
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
/** @param {object} obj */
const writeNDJSON = (obj) => {
if (res.writableEnded || res.destroyed) return;
res.write(`${JSON.stringify(obj)}\n`);
};
/** @type {import('../../js/containers/scribeDoc.js').ScribeDoc | undefined} */
let doc;
const emitted = new Set();
try {
doc = await scribe.openDocument({ pdfFiles: [pdfArrayBuffer] });
log(`imported: ${doc.inputData.pageCount} page(s)`);
// scribe.js's recognize loop fires per-page events:
// - `recognize` with status: 'sending' when dispatched to Textract
// - `recognize` with status: 'received' when raw JSON returns
// - `convert` once scribe.js has parsed the raw JSON into its OcrPage model
// We log 'sending'/'received' for request-level visibility,
// and on `convert` we emit one NDJSON line per page.
// At that point doc.ocr[ENGINE_NAME][n] holds the parsed OcrPage,
// doc.layoutDataTables.pages[n] holds the per-page tables,
// and doc.convertPageWarn[n] holds the per-page conversion warning.
doc.progressHandler = (msg) => {
try {
if (msg && msg.type === 'recognize' && msg.info && msg.info.engineName === ENGINE_NAME) {
if (msg.info.status === 'sending') log(`page ${msg.n} → Textract`);
else if (msg.info.status === 'received') log(`page ${msg.n} ← Textract`);
} else if (msg && msg.type === 'convert' && msg.info && msg.info.engineName === ENGINE_NAME) {
const n = msg.n;
if (typeof n === 'number' && !emitted.has(n)) {
const page = doc?.ocr[ENGINE_NAME]?.[n];
if (page) {
emitted.add(n);
const strippedPage = scribe.utils.ocr.removeCircularRefsOcr([page])[0];
const strippedTables = scribe.layout.removeCircularRefsDataTables([doc.layoutDataTables.pages[n]])[0];
const warn = doc.convertPageWarn[n] || {};
log(`page ${n} converted, streaming`);
writeNDJSON({
pageNum: n, page: strippedPage, dataTables: strippedTables, warn,
});
} else {
log(`page ${n} convert event fired but no OcrPage on doc (skipping)`);
}
}
}
} catch (err) {
// Never let progress handler errors take down recognition.
console.error(`[${reqId}] progressHandler error:`, formatErr(err));
}
};
// Surface per-page Textract failures (throttling, invalid input, etc.)
// with the real error message so the client knows why a page is missing.
// Document-scoped warnings (no `page`) become top-level error markers in the stream.
doc.warningHandler = ({ message, page }) => {
log(`warning${page !== undefined ? ` (page ${page})` : ''}: ${message}`);
if (page !== undefined) {
if (!emitted.has(page)) {
emitted.add(page);
writeNDJSON({ pageNum: page, error: { message } });
}
} else {
writeNDJSON({ error: { message } });
}
};
doc.errorHandler = ({ message, page }) => {
log(`error${page !== undefined ? ` (page ${page})` : ''}: ${message}`);
if (page !== undefined) {
if (!emitted.has(page)) {
emitted.add(page);
writeNDJSON({ pageNum: page, error: { message } });
}
} else {
writeNDJSON({ error: { message } });
}
};
await doc.recognize({
model: /** @type {any} */ (RecognitionModelTextract),
modelOptions: {
region: TEXTRACT_REGIONS.length > 1 ? TEXTRACT_REGIONS : TEXTRACT_REGIONS[0],
analyzeLayout: ANALYZE_LAYOUT,
analyzeTables: ANALYZE_TABLES,
},
signal: ac.signal,
});
log(`recognition complete, ${emitted.size} page(s) streamed`);
// Safety net for a page that fires neither a `convert` event nor a warning.
// With doc.warningHandler wired up this should be empty in practice,
// but the client must not silently drop a page.
const pageAll = doc.ocr[ENGINE_NAME] || [];
for (let n = 0; n < pageAll.length; n++) {
if (!emitted.has(n)) {
log(`page ${n} produced no OCR data and no warning, emitting fallback error marker`);
writeNDJSON({ pageNum: n, error: { message: 'No OCR data produced for page' } });
}
}
} catch (err) {
/** @type {any} */
const e = err;
if (e && e.name === 'AbortError') {
log(`aborted mid-recognition; ${emitted.size} page(s) completed this run`);
return;
}
console.error(`[${reqId}] recognition error:`, formatErr(e));
writeNDJSON({ error: { message: e && e.message ? e.message : String(e) } });
throw e;
} finally {
try { if (doc) await doc.close(); } catch (_) { /* ignore */ }
if (!res.writableEnded) res.end();
log('request closed');
}
};
const server = http.createServer(async (req, res) => {
writeCORS(res);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
if (req.method === 'POST' && req.url === '/ocr') {
try {
await handleOCR(req, res);
} catch (err) {
console.error(err);
if (!res.headersSent) {
res.statusCode = err.statusCode || 500;
res.end(err.message || 'Server error');
} else if (!res.writableEnded) {
res.end();
}
}
return;
}
res.statusCode = 404;
res.end('Not found');
});
// Pre-load the shared OCR/font workers once at startup
// so the first request doesn't pay the init cost.
// The shared pool is reused across all concurrent requests by design.
await scribe.init();
server.listen(PORT, () => {
console.log(`scribe.js Textract proxy listening on http://localhost:${PORT}`);
console.log(` regions: ${TEXTRACT_REGIONS.join(', ')}`);
console.log(` analyzeLayout=${ANALYZE_LAYOUT} analyzeTables=${ANALYZE_TABLES}`);
});