excel

Docs › Functions and Node

Server second pass (Node example)

Run validate() again on the cleaned rows the browser sent, with the same rules.json. An Express example, its output, and server hardening.

The browser (<excel-82> or check()) does the first pass and sends cleaned rows. The server does not reopen the file; it runs validate() again with the same rules.json.

browser                                          server
check(file, rules.json)  ── { schema, rows, report } ──▶  1. schema.hash == rulesHash(rules.json) ?  else 409
  (first pass: file,           (+ file, upload-file=include)  2. validate(rows, rules.json)        on failure 422 + CSV
   header, cells)                                         3. (file included) sha256(file) == report.file.sha256 ?
                                                          4. save result.rows

The rules file

The browser (<excel-82 rules="/rules.json">) and the server share one file.

{
  "$schema": "https://82excel.com/schema/rules-1.json",
  "id": "orders-upload",
  "version": "1",
  "columns": [
    { "key": "orderNo", "header": "주문번호", "nullable": false, "unique": true, "pattern": "^[A-Z]{2}\\d{6}$" },
    { "key": "date", "header": "주문일", "type": "date", "nullable": false },
    { "key": "phone", "header": "전화번호", "type": "kr.phone", "phone": { "kind": "mobile" } },
    { "key": "amount", "header": "금액", "type": "kr.amount", "min": 0 }
  ],
  "rows": { "min": 1, "max": 10000 }
}

The server (server.mjs)

// server.mjs — the second pass: re-check what the browser sent, with the same rules.json.
import express from 'express';
import { readFile } from 'node:fs/promises';
import { makeErrorReport, rulesHash, sha256, validate } from '82excel';

const rules = JSON.parse(await readFile(new URL('./rules.json', import.meta.url), 'utf8'));
const RULES_HASH = await rulesHash(rules); // the same value as the browser's report.schemaHash
// validate() reports rows.max itself (ROWS_TOO_MANY), but only after reading every row. The browser never
// sends blank rows, so refusing longer arrays up front costs a real upload nothing.
const MAX_ROWS = rules.rows?.max ?? 10000;

/** validate() and, on failure, a 422 with the CSV error report. Returns the cleaned rows or null. */
async function recheck(rows, res) {
  if (Array.isArray(rows) && rows.length > MAX_ROWS) {
    res.status(413).json({ error: `at most ${MAX_ROWS} rows` });
    return null;
  }
  let result;
  try {
    result = validate(rows, rules); // throws "82excel: …" when rows is not an array of plain objects
  } catch (e) {
    res.status(400).json({ error: e.message });
    return null;
  }
  // Cell and row rules, rows.min / rows.max and failOn: the same errors as the browser's check().
  if (!result.ok) {
    const csv = Buffer.from(await makeErrorReport(result, 'csv').arrayBuffer());
    res.status(422).type('text/csv; charset=utf-8').attachment('errors.csv').send(csv);
    return null;
  }
  return result.rows;
}

const app = express();

// 1) Default upload (upload-as="json"): { fileName, sheet, schema, columns, rows, report }.
app.post('/api/orders', express.json({ limit: '20mb' }), async (req, res) => {
  const body = req.body ?? {};
  if (body.schema?.hash !== RULES_HASH) return res.status(409).json({ error: 'rules changed; reload the page' });
  const rows = await recheck(body.rows, res);
  if (!rows) return;
  // body.report is only a hint (it came from the browser): log it, never trust it.
  res.json({ saved: rows.length, first: rows[0] });
});

// 2) upload-file="include": multipart with file, data (the JSON above without report) and report.
app.post('/api/orders-with-file', express.raw({ type: 'multipart/form-data', limit: '60mb' }), async (req, res) => {
  // Node's own multipart parser (undici); multer or busboy work just as well.
  let form, data, report;
  try {
    form = await new Request('http://local/', {
      method: 'POST',
      headers: { 'content-type': req.get('content-type') },
      body: req.body,
    }).formData();
    data = JSON.parse(String(form.get('data') ?? '{}'));
    report = JSON.parse(String(form.get('report') ?? '{}'));
  } catch {
    return res.status(400).json({ error: 'malformed multipart body or JSON field' }); // not a 500
  }
  const file = form.get('file');
  if (!(file instanceof File)) return res.status(400).json({ error: 'file is missing' });
  if (data?.schema?.hash !== RULES_HASH) return res.status(409).json({ error: 'rules changed; reload the page' });
  const bytes = new Uint8Array(await file.arrayBuffer());
  if ((await sha256(bytes)) !== report?.file?.sha256) return res.status(400).json({ error: 'file does not match the report' });
  const rows = await recheck(data.rows, res);
  if (!rows) return;
  res.json({ saved: rows.length, file: file.name, bytes: bytes.length });
});

const server = app.listen(process.env.PORT ?? 3000, () => console.log(`listening on ${server.address().port}`));
export default server;
  • Default upload (/api/orders). <excel-82 rules=… upload=…> with 0.2 rules sends JSON { fileName, sheet, schema: { id, version, hash }, columns, rows, report }. Size express.json({ limit }) for your largest file.
  • Hash check. If body.schema.hash differs from await rulesHash(rules), the page checked with other rules: answer 409 and ask for a reload. readRows, previewRows, locale and telemetry are not part of the hash, so adding them in the browser keeps it equal.
  • Check again. validate(body.rows, rules) runs the same code as the browser's check() and gives the same results. It keeps the $row Excel row numbers, so positions match what the user saw, and cleaned values give the same result again (idempotent). On failure answer 422 with the CSV from makeErrorReport(result, 'csv') (UTF-8 BOM, personal data masked). Cell issues carry header, the header from the rules.
  • Row counts and failOn. validate() applies rows.min/rows.max (result rows, ROWS_TOO_FEW/ROWS_TOO_MANY) and failOn exactly like check(), so they appear in the 422 CSV. The example also refuses arrays longer than rows.max with 413 before any work.
  • Save result.rows. Store the cleaned rows validate() returns, not body.rows; keys that are not columns are dropped. result.rows is never masked.
  • With the file (/api/orders-with-file). <excel-82 upload-file="include"> sends multipart file, data (the JSON above without report) and report. Compare report.file.sha256 with await sha256(bytes) to know it is the file the browser checked. multer or busboy work too.
  • JS row hooks. Hooks passed to validate(…, { rowRules: [fn] }) in the browser are not in the rules JSON; pass the same hooks on the server.

Server hardening

Anyone can craft the request body, so treat the second-pass route as taking untrusted input:

  • Body limit. Size express.json({ limit }) (for multipart express.raw({ limit }) or multer's limits) to your largest real upload: rows × columns × average cell length.
  • Row cap. Set rows.max in the rules and compare rows.length before calling validate() (the 413 above). validate() reports ROWS_TOO_MANY too, but only after reading every row.
  • Per-cell length cap. Values over the column's maxLength (default 32,767, Excel's cell limit) are CELL_LENGTH and skip the pattern, personal data detection and number parsing. Set a small maxLength on short fields such as names and codes.
  • validate() is linear-time. In 0.2.0, personal data detection and type parsing take time linear in the input. Only what you add can be slow: avoid nested repeats such as (a+)+ in pattern, and keep rowRules JS hooks light.
  • The report is a hint. Decide on validate(), never on the report; authenticate and rate-limit as for any other API.

Try it

try.mjs plays the browser: it runs check() on a CSV in Node and sends the same bodies <excel-82> sends.

// try.mjs — plays the browser: check() a CSV in Node, then send what <excel-82> sends.
import { check } from '82excel';

process.env.PORT = '0';
const { default: server } = await import('./server.mjs');
if (!server.listening) await new Promise((r) => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;
const rules = (await import('./rules.json', { with: { type: 'json' } })).default;

const csv = '주문번호,주문일,전화번호,금액\r\nAB000001,2024.1.5,010 1234 5678,"₩12,000"\r\nAB000002,2024-01-06,+82 10-9876-5432,500원\r\n';
const file = new File([csv], 'orders.csv', { type: 'text/csv' });
const r = await check(file, { ...rules, readRows: true });
console.log('browser check:', r.ok, JSON.stringify(r.rows));

// The JSON body <excel-82> builds for v2 rules (spec §5 P-1).
const data = {
  fileName: file.name,
  sheet: r.info.sheets[0].name,
  schema: { id: r.report.schemaId, version: r.report.schemaVersion, hash: r.report.schemaHash },
  columns: r.columns.map((c) => c.key),
  rows: r.rows,
};
const post = (path, body, type) => fetch(base + path, { method: 'POST', body, headers: type ? { 'content-type': type } : {} });
const json = (path, body) => post(path, JSON.stringify(body), 'application/json');
const show = async (label, res) => console.log(`${label}: ${res.status}\n${(await res.text()).replace(/^/, '').trimEnd()}`);

await show('1 valid', await json('/api/orders', { ...data, report: r.report }));

// Someone skips the browser and posts rows directly.
const bad = { ...data, rows: [{ $row: 2, orderNo: 'X1', date: '2024-02-30', phone: '010-123-4567', amount: -5 }] };
await show('2 tampered', await json('/api/orders', bad));

await show('3 old rules', await json('/api/orders', { ...data, schema: { ...data.schema, hash: 'sha256:0' } }));
await show('4 not rows', await json('/api/orders', { ...data, rows: 'x' }));

const form = (f) => {
  const fd = new FormData();
  fd.append('file', f);
  fd.append('data', JSON.stringify(data));
  fd.append('report', JSON.stringify(r.report));
  return fd;
};
await show('5 include file', await post('/api/orders-with-file', form(file)));
await show('6 other file', await post('/api/orders-with-file', form(new File(['주문번호\r\nZZ999999\r\n'], 'orders.csv'))));

// rows.min is 1: an empty upload fails as it would in the browser.
await show('7 no rows', await json('/api/orders', { ...data, rows: [] }));

const broken = form(file);
broken.set('data', '{not json');
await show('8 malformed data', await post('/api/orders-with-file', broken));

server.close();
npm i 82excel express
node try.mjs

Output (82excel 0.2.0, express 5.2.1, Node 24; the port varies):

listening on 4211
browser check: true [{"$row":2,"orderNo":"AB000001","date":"2024-01-05","phone":"010-1234-5678","amount":12000},{"$row":3,"orderNo":"AB000002","date":"2024-01-06","phone":"010-9876-5432","amount":500}]
1 valid: 200
{"saved":2,"first":{"$row":2,"orderNo":"AB000001","date":"2024-01-05","phone":"010-1234-5678","amount":12000}}
2 tampered: 422
Row,Column,Header,Original value,Message,Code,Severity
2,,주문번호,X1,Row 2 (주문번호): Does not match the required pattern,CELL_PATTERN,error
2,,주문일,2024-02-30,Row 2 (주문일): Date does not exist,CELL_DATE,error
2,,전화번호,010-***-4567,Row 2 (전화번호): Invalid mobile phone number,KR_PHONE,error
2,,금액,-5,Row 2 (금액): Must be at least 0,CELL_RANGE,error
3 old rules: 409
{"error":"rules changed; reload the page"}
4 not rows: 400
{"error":"82excel: rows must be an array of row objects"}
5 include file: 200
{"saved":2,"file":"orders.csv","bytes":134}
6 other file: 400
{"error":"file does not match the report"}
7 no rows: 422
Row,Column,Header,Original value,Message,Code,Severity
1,,,,"Expected at least 1 rows, found 0",ROWS_TOO_FEW,error
8 malformed data: 400
{"error":"malformed multipart body or JSON field"}
  • 1: rows that passed in the browser pass on the server: 2024.1.5 → 2024-01-05, ₩12,000 → 12000, +82 10-9876-5432 → 010-9876-5432.
  • 2: a request made without the browser is caught by validate(). The phone number is masked as 010-***-4567 in the report, and the Header column is the rules' header.
  • 3: a page that checked with other rules gets 409.
  • 5–6: with the file included, sha256 tells whether it is the file the browser checked.
  • 7: validate() checks rows.min too (ROWS_TOO_FEW, at header row 1, no column).
  • 8: a malformed JSON field in the multipart body is a 400, not a 500.

Other languages

Server SDKs for Java, PHP, Python, C# and Go are in preparation. Until then, run validate() in a Node service, or port it against the conformance suite.