From 390d66890b41ef54f3aa2f38d6f7a3ef27a71578 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Tue, 18 Aug 2026 00:53:58 +0000 Subject: [PATCH] u --- crashreport.md | 184 +++++++++++++++++++++++++++++++++++ src/endpoints/crashreport.js | 65 +++++++++++++ src/endpoints/index.js | 18 +++- 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 crashreport.md create mode 100644 src/endpoints/crashreport.js diff --git a/crashreport.md b/crashreport.md new file mode 100644 index 0000000..3bba40d --- /dev/null +++ b/crashreport.md @@ -0,0 +1,184 @@ +# Crash Report Endpoint + +Clients (POS apps running in a browser or Node.js) can submit crash / uncaught-error details to the sync server for later inspection. The server writes each report to a plain-text file under `logs/crash/`. + +``` +POST /v1/crashreport +``` + +Paths may also be called as `/api/v1/crashreport` — both resolve to the same handler. + +--- + +## Request + +- **Method:** `POST` +- **Content-Type:** `application/json` +- **Body:** any JSON object you want to record. Keep it free-form; common fields are shown below. + +```json +{ + "app": "my-pos-app", + "version": "1.2.3", + "platform": "win32", + "userAgent": "Mozilla/5.0 ...", + "message": "Cannot read properties of undefined (reading 'price')", + "stack": "TypeError: Cannot read properties of undefined (reading 'price')\\n at ...", + "context": { + "currentView": "checkout", + "orderId": "abc-123" + }, + "device": { + "id": "clerk-01", + "display": 1920 + } +} +``` + +Nothing is validated server-side: the body is logged as-is (`JSON.stringify(body, null, 2)`). + +--- + +## Responses + +| Status | Body | Meaning | +|---|---|---| +| `200` | `{ "Message": "OK", "file": "-.txt" }` | Report saved | +| `400` | `{ "Message": "Invalid JSON body." }` | Body is not valid JSON | + +Non-JSON or empty bodies are treated as follows: + +- **Empty body** → saved as an empty object `{}`. +- **Invalid JSON** (e.g. a raw text dump) → rejected with `400`. + +--- + +## Log file format + +Each accepted report is written to: + +``` +logs/crash/-.txt +``` + +- `` — local server time, `YYYYMMDD-HHMMSS`. +- `` — the client's remote address (`::ffff:` IPv4 prefix is stripped; `:` is replaced with `_`). + +Two reports from the same client within the same second overwrite each other. Include a unique id inside the body if you need to guarantee every report is preserved. + +Example file: + +``` +timestamp: 2026-08-17T09:41:22.731Z +ip: ::ffff:127.0.0.1 +user-agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch) + +{ + "app": "my-pos-app", + "message": "Cannot read properties of undefined", + "stack": "TypeError: ..." +} +``` + +The log directory `logs/crash/` is created automatically on first use. + +--- + +## Usage from Node.js + +```js +// plain fetch (Node 18+) +const baseUrl = 'https://192.168.1.10:4443'; +const AUTH_TOKEN = ''; // optional if your server does not enforce it + +fetch(`${baseUrl}/v1/crashreport`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + app: 'my-pos-app', + version: '1.2.3', + message: error.message, + stack: error.stack, + }), +}) + .then(async (res) => console.log(res.status, await res.json())) + .catch((err) => console.error('could not send crash report', err)); +``` + +Because the server uses a self-signed certificate, set `rejectUnauthorized: false` when using `node-fetch` or Node's `https` module: + +```js +import https from 'node:https'; +import fetch from 'node-fetch'; + +const agent = new https.Agent({ rejectUnauthorized: false }); + +fetch('https://:4443/v1/crashreport', { + method: 'POST', + agent, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app: 'my-pos-app', message: error.message, stack: error.stack }), +}) + .then(async (res) => console.log(res.status, await res.json())); +``` + +Hook it into your global error handlers so nothing is lost: + +```js +process.on('uncaughtException', (err) => { + void sendCrashReport(err); + // ...your own logging / shutdown +}); +process.on('unhandledRejection', (reason) => { + void sendCrashReport(reason instanceof Error ? reason : new Error(String(reason))); +}); +``` + +--- + +## Usage from the browser + +Use `fetch` with `mode: 'no-cors'` **or** a normal `cors` request — the server sends `Access-Control-Allow-Origin: *`, so a plain `fetch` works from any origin. + +```js +function sendCrashReport(payload) { + return fetch('https://:4443/v1/crashreport', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(async (res) => { + const json = await res.json(); + console.log(`crash report ${res.ok ? 'saved' : 'failed'}`, json); + return json; + }) + .catch((err) => console.error('could not send crash report', err)); +} +``` + +Wire it into `window.onerror` and `unhandledrejection`: + +```js +window.addEventListener('error', (event) => { + sendCrashReport({ + app: 'my-pos-app', + version: appVersion, + message: event.message, + stack: event.error?.stack, + context: { url: location.href }, + }); +}); + +window.addEventListener('unhandledrejection', (event) => { + sendCrashReport({ + app: 'my-pos-app', + version: appVersion, + message: event.reason?.message || String(event.reason), + stack: event.reason?.stack, + }); +}); +``` + +> **Note:** if you use `mode: 'no-cors'` the browser will send the request but you will not be able to read the JSON response body — the report is still saved server-side. \ No newline at end of file diff --git a/src/endpoints/crashreport.js b/src/endpoints/crashreport.js new file mode 100644 index 0000000..e4785c3 --- /dev/null +++ b/src/endpoints/crashreport.js @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import nodePath from 'node:path'; +import { sendJson } from '../http.js'; +import { logger } from '../logger.js'; + +export const method = 'POST'; +export const path = '/v1/crashreport'; + +const CRASH_LOG_DIR = process.env.CRASH_LOG_DIR || nodePath.join('logs', 'crash'); + +function sanitizeIp(ip) { + const value = String(ip || '0.0.0.0'); + const withoutPrefix = value.replace(/^::ffff:/, ''); + return withoutPrefix.replace(/[:]/g, '_'); +} + +function timestampPart() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return ( + `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + + `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` + ); +} + +function parseBody(buffer) { + if (!buffer || !buffer.length) { + return {}; + } + try { + const parsed = JSON.parse(buffer.toString('utf8')); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return null; + } +} + +export function handle(req, res) { + const remoteAddress = req.socket?.remoteAddress ?? 'unknown'; + const ip = sanitizeIp(remoteAddress); + const body = parseBody(req.rawBody); + + if (body === null) { + return sendJson(res, 400, { Message: 'Invalid JSON body.' }); + } + + const fileName = `${timestampPart()}-${ip}.txt`; + const filePath = nodePath.join(CRASH_LOG_DIR, fileName); + + fs.mkdirSync(CRASH_LOG_DIR, { recursive: true }); + + const lines = [ + `timestamp: ${new Date().toISOString()}`, + `ip: ${remoteAddress}`, + `user-agent: ${req.headers['user-agent'] ?? ''}`, + '', + JSON.stringify(body, null, 2), + '', + ]; + + fs.writeFileSync(filePath, lines.join('\n')); + logger.warn(`crash report saved to ${filePath}`); + + return sendJson(res, 200, { Message: 'OK', file: fileName }); +} \ No newline at end of file diff --git a/src/endpoints/index.js b/src/endpoints/index.js index a5a9e04..a6151ec 100644 --- a/src/endpoints/index.js +++ b/src/endpoints/index.js @@ -1,6 +1,7 @@ import * as category from './category.js'; import * as cimage from './cimage.js'; import * as client from './client.js'; +import * as crashreport from './crashreport.js'; import * as customer from './customer.js'; import * as customergroup from './customergroup.js'; import * as deletedEntity from './deleted-entity.js'; @@ -12,4 +13,19 @@ import * as pimage from './pimage.js'; import * as product from './product.js'; import * as productcomposite from './productcomposite.js'; -export const endpoints = [client, newpin, init, category, product, productcomposite, deletedEntity, pimage, cimage, customergroup, customer, order, orderSearch]; +export const endpoints = [ + client, + newpin, + init, + category, + product, + productcomposite, + deletedEntity, + pimage, + cimage, + customergroup, + customer, + order, + orderSearch, + crashreport, +];