reedach-server 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +3 -3
- package/src/check.mjs +8 -4
- package/src/cli.mjs +1 -1
- package/src/http.mjs +32 -5
- package/src/morph.mjs +3 -1
- package/src/spell.mjs +2 -1
- package/src/worker.mjs +17 -0
package/README.md
CHANGED
|
@@ -110,9 +110,10 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
|
|
|
110
110
|
| `REEDACH_KEYS` | — (обязательно) | ключи доступа |
|
|
111
111
|
| `PORT` | `3000` | порт |
|
|
112
112
|
| `HOST` | `0.0.0.0` | адрес |
|
|
113
|
-
| `REEDACH_MAX_BYTES` | `
|
|
113
|
+
| `REEDACH_MAX_BYTES` | `200000` | максимальный размер запроса в байтах (200 КБ — около 100 тысяч знаков русского текста) |
|
|
114
114
|
| `REEDACH_CONCURRENCY` | `2` | сколько проверок идёт одновременно |
|
|
115
115
|
| `REEDACH_QUEUE` | `20` | сколько запросов может ждать; остальные получат 503 |
|
|
116
|
+
| `REEDACH_TIMEOUT` | `20000` | сколько миллисекунд может идти одна проверка; дольше — 503 |
|
|
116
117
|
| `REEDACH_DEMO` | — | `1` — показывать страницу-пример по адресу `/demo/` |
|
|
117
118
|
|
|
118
119
|
## Как устроена орфография
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reedach-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Сервер Reedach: орфография (hunspell), морфология (Az.js + OpenCorpora) и подсказки по стилю для русского текста — библиотекой или по HTTP",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dima Faller <dima@dimafaller.ru>",
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"az": "0.2.3",
|
|
42
42
|
"dictionary-en": "4.0.0",
|
|
43
43
|
"dictionary-ru": "3.0.0",
|
|
44
|
-
"reedach": "0.
|
|
44
|
+
"reedach": "0.2.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"reedach-widget": "^0.
|
|
47
|
+
"reedach-widget": "^0.2.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
50
|
"reedach-widget": {
|
package/src/check.mjs
CHANGED
|
@@ -13,7 +13,8 @@ const identity=b=>({text:b.text,kind:['h','li'].includes(b.kind)?b.kind:'p',map:
|
|
|
13
13
|
|
|
14
14
|
// input: {markdown | text | blocks:[{text,kind}], ignore:[own words], disable:[rule ids or categories], checks:[...]}
|
|
15
15
|
// Markdown and text hints point into the source string; block hints carry `block` and point into that block's text.
|
|
16
|
-
|
|
16
|
+
// Options for running elsewhere: `suggester` and `misspelled` replace the hunspell calls (the HTTP server's workers ask its main thread).
|
|
17
|
+
export async function check(input,{suggester:sg=suggester(),misspelled:ms=misspelled,limits={}}={}){
|
|
17
18
|
const lim={...LIMITS,...limits},{markdown,text,blocks:raw}=input??{};
|
|
18
19
|
let blocks,blocksInput=false;
|
|
19
20
|
if(typeof markdown==='string')blocks=sourceBlocks(markdown);
|
|
@@ -23,7 +24,7 @@ export async function check(input,{suggester:sg=suggester(),limits={}}={}){
|
|
|
23
24
|
const list=(v,name)=>{if(v==null)return [];if(!Array.isArray(v)||v.some(x=>typeof x!=='string'))throw bad('Поле '+name+' должно быть списком строк');return v.slice(0,5000);};
|
|
24
25
|
const ignore=list(input.ignore,'ignore'),disable=new Set(list(input.disable,'disable')),checks=new Set(input.checks==null?CHECKS:list(input.checks,'checks'));
|
|
25
26
|
const [spelling,morph]=await Promise.all([
|
|
26
|
-
checks.has('spelling')?findSpelling(blocks,ignore,sg,lim):[],
|
|
27
|
+
checks.has('spelling')?findSpelling(blocks,ignore,sg,lim,ms):[],
|
|
27
28
|
checks.has('morph')?morphCheck(blocks).catch(()=>[]):[],
|
|
28
29
|
]);
|
|
29
30
|
const local=checks.has('style')?checkStyle(blocks):[];
|
|
@@ -32,8 +33,8 @@ export async function check(input,{suggester:sg=suggester(),limits={}}={}){
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// Misspelled words minus own words; suggestions for the first few within a time budget, the rest get null (ask /v1/suggest).
|
|
35
|
-
async function findSpelling(blocks,ignore,sg,lim){
|
|
36
|
-
const found=(await
|
|
36
|
+
async function findSpelling(blocks,ignore,sg,lim,ms){
|
|
37
|
+
const found=(await ms(words(blocks.map(b=>b.text).join('\n')))).filter(w=>!known(w,ignore));
|
|
37
38
|
const result=found.map(word=>({word,suggestions:null}));
|
|
38
39
|
const deadline=new Promise(resolve=>setTimeout(resolve,lim.suggestBudget).unref());
|
|
39
40
|
await Promise.race([deadline,Promise.all(result.slice(0,lim.suggestWords).map(item=>sg.suggest(item.word).then(s=>{item.suggestions=s;},()=>{})))]);
|
|
@@ -52,4 +53,7 @@ export function rules(){
|
|
|
52
53
|
return {categories:{...CATEGORIES,spelling:'Орфография'},rules:[...RULES.map(r=>pick(r.id,r)),...Object.entries(BUILTIN).map(([id,r])=>pick(id,r)),...Object.entries(MORPH_RULES).map(([id,r])=>pick(id,r))]};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// hunspell for the HTTP server's workers, run in the main thread.
|
|
57
|
+
export const spellHost={misspelled:list=>misspelled(list),suggest:word=>suggester().suggest(word)};
|
|
58
|
+
|
|
55
59
|
export function closeChecks(){shared?.close();shared=null;}
|
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ const env=process.env,num=(name,fallback)=>{const v=Number(env[name]);return Num
|
|
|
6
6
|
let keys,server;
|
|
7
7
|
try{
|
|
8
8
|
keys=parseKeys(env.REEDACH_KEYS);if(!keys.length)throw new Error('Задайте REEDACH_KEYS: ключ=https://ваш-сайт.ru; второй-ключ');
|
|
9
|
-
server=createServer({keys,maxBytes:num('REEDACH_MAX_BYTES',
|
|
9
|
+
server=createServer({keys,maxBytes:num('REEDACH_MAX_BYTES',200_000),concurrency:num('REEDACH_CONCURRENCY',2),queue:num('REEDACH_QUEUE',20),timeout:num('REEDACH_TIMEOUT',20000),demo:env.REEDACH_DEMO==='1'});
|
|
10
10
|
}catch(e){console.error('reedach: '+e.message);process.exit(1);}
|
|
11
11
|
const port=num('PORT',3000),host=env.HOST||'0.0.0.0';
|
|
12
12
|
server.listen(port,host,()=>console.log('reedach: слушаю '+host+':'+port+', ключей: '+keys.length+(env.REEDACH_DEMO==='1'?', пример: /demo/':'')));
|
package/src/http.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import http from 'node:http';
|
|
|
2
2
|
import {readFileSync} from 'node:fs';
|
|
3
3
|
import {fileURLToPath} from 'node:url';
|
|
4
4
|
import {createHash,timingSafeEqual} from 'node:crypto';
|
|
5
|
-
import {
|
|
5
|
+
import {Worker} from 'node:worker_threads';
|
|
6
|
+
import {suggest,rules,spellHost} from './check.mjs';
|
|
6
7
|
// HTTP API. Every /v1 call needs a key. A key may list the sites whose pages can call it from the browser;
|
|
7
8
|
// calls without an Origin header (from a site's own server) work with any valid key.
|
|
8
9
|
// The server keeps nothing: texts are not stored and not logged.
|
|
@@ -31,9 +32,35 @@ function demoFiles(){
|
|
|
31
32
|
return new Map(Object.entries(map).map(([url,p])=>[url,{body:readFileSync(file(p)),type:types[p.split('.').pop()]}]));
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// Checks run in worker threads, at most one per slot: the server keeps answering while a long text is checked,
|
|
36
|
+
// and a check running longer than `timeout` ms is stopped together with its worker; the next call starts a fresh one.
|
|
37
|
+
// Workers ask this thread for hunspell (see worker.mjs); an answer to a stopped worker is dropped.
|
|
38
|
+
function workers(timeout){
|
|
39
|
+
const answer=(w,{id,op,arg})=>Promise.resolve().then(()=>op==='suggest'?spellHost.suggest(arg):spellHost.misspelled(arg))
|
|
40
|
+
.then(value=>({id,value}),e=>({id,error:{message:e.message,status:e.status,code:e.code}}))
|
|
41
|
+
.then(reply=>{try{w.postMessage({answer:reply});}catch{}});
|
|
42
|
+
const free=[],all=new Set();
|
|
43
|
+
const spawn=()=>{const w=new Worker(new URL('./worker.mjs',import.meta.url));w.unref();all.add(w);w.once('exit',()=>all.delete(w));return w;};
|
|
44
|
+
return {
|
|
45
|
+
run(input){
|
|
46
|
+
const w=free.pop()||spawn();
|
|
47
|
+
return new Promise((resolve,reject)=>{
|
|
48
|
+
const end=(keep,fn,value)=>{clearTimeout(timer);w.off('message',onMessage).off('error',onError).off('exit',onExit);if(keep)free.push(w);else w.terminate();fn(value);};
|
|
49
|
+
const onMessage=m=>m.ask?answer(w,m.ask):m.error?end(true,reject,Object.assign(new Error(m.error.message),m.error)):end(true,resolve,m.result);
|
|
50
|
+
const onError=e=>end(false,reject,e),onExit=()=>end(false,reject,fail(500,'internal','Проверка прервалась'));
|
|
51
|
+
const timer=setTimeout(()=>end(false,reject,fail(503,'timeout','Текст проверяется слишком долго')),timeout);
|
|
52
|
+
w.on('message',onMessage).on('error',onError).on('exit',onExit);
|
|
53
|
+
// Copying to the worker throws at once on JSON nested thousands deep; the worker stays idle and goes back to the pool.
|
|
54
|
+
try{w.postMessage({input});}catch{end(true,reject,fail(422,'validation','Слишком глубокая вложенность JSON'));}
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
close(){for(const w of all)w.terminate();},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createServer({keys,maxBytes=200_000,concurrency=2,queue=20,timeout=20000,demo=false}={}){
|
|
35
62
|
if(!keys?.length)throw new Error('Нужен хотя бы один ключ доступа');
|
|
36
|
-
const pages=demo?demoFiles():null;
|
|
63
|
+
const pages=demo?demoFiles():null,pool=workers(timeout);
|
|
37
64
|
const entries=keys.map(k=>({...k,hash:digest(k.key)})),siteOrigins=new Set(entries.flatMap(k=>k.origins));
|
|
38
65
|
const find=token=>{const h=digest(token);let hit=null;for(const k of entries)if(timingSafeEqual(h,k.hash))hit=k;return hit;};
|
|
39
66
|
let active=0;const waiting=[];
|
|
@@ -61,7 +88,7 @@ export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,dem
|
|
|
61
88
|
if(!key)throw fail(401,'unauthorized','Нужен ключ доступа');
|
|
62
89
|
if(origin&&!key.origins.includes(origin))throw fail(403,'origin','Этот ключ не работает на этом сайте');
|
|
63
90
|
if(url.pathname==='/v1/rules'&&req.method==='GET')return send(200,rules());
|
|
64
|
-
const route=url.pathname==='/v1/check'?b=>
|
|
91
|
+
const route=url.pathname==='/v1/check'?b=>pool.run(b):url.pathname==='/v1/suggest'?b=>suggest(b?.words):null;
|
|
65
92
|
if(!route)throw fail(404,'not_found','Нет такого адреса');
|
|
66
93
|
if(req.method!=='POST')throw fail(405,'method','Используйте POST');
|
|
67
94
|
if(!/^application\/json\b/i.test(req.headers['content-type']||''))throw fail(415,'content_type','Нужен JSON');
|
|
@@ -74,7 +101,7 @@ export function createServer({keys,maxBytes=1_000_000,concurrency=2,queue=20,dem
|
|
|
74
101
|
send(status,{error:{code:e.code||'internal',message:status===500?'Внутренняя ошибка':e.message}},e.code==='busy'?{'retry-after':'5'}:status===413?{connection:'close'}:{});
|
|
75
102
|
}
|
|
76
103
|
});
|
|
77
|
-
server.requestTimeout=30000;server.headersTimeout=10000;
|
|
104
|
+
server.requestTimeout=30000;server.headersTimeout=10000;server.on('close',()=>pool.close());
|
|
78
105
|
return server;
|
|
79
106
|
}
|
|
80
107
|
|
package/src/morph.mjs
CHANGED
|
@@ -5,9 +5,11 @@ const require=createRequire(import.meta.url),Az=require('az'),DICTS=path.join(pa
|
|
|
5
5
|
let ready=null;
|
|
6
6
|
const init=()=>ready??=new Promise((resolve,reject)=>Az.Morph.init(DICTS,error=>error?reject(error):resolve()));
|
|
7
7
|
const cache=new Map();
|
|
8
|
+
// Russian words are shorter than 40 letters; Az.js spends seconds on a made-up word of thousands, so those are not parsed.
|
|
9
|
+
const MAX_WORD=40;
|
|
8
10
|
function parse(word){
|
|
9
11
|
const key=word.toLowerCase();let hit=cache.get(key);
|
|
10
|
-
if(!hit){let p;try{p=Az.Morph(key)[0];}catch{}hit={pos:p?.tag?.POS||'',cas:p?.tag?.CAse||'',lemma:String(p?.normalize()?.word||key),score:p?.score||0};if(cache.size>50000)cache.clear();cache.set(key,hit);}
|
|
12
|
+
if(!hit){let p;try{if(key.length<=MAX_WORD)p=Az.Morph(key)[0];}catch{}hit={pos:p?.tag?.POS||'',cas:p?.tag?.CAse||'',lemma:String(p?.normalize()?.word||key),score:p?.score||0};if(cache.size>50000)cache.clear();cache.set(key,hit);}
|
|
11
13
|
return hit;
|
|
12
14
|
}
|
|
13
15
|
const RULES={
|
package/src/spell.mjs
CHANGED
|
@@ -11,7 +11,8 @@ const spellError=(code,message)=>Object.assign(new Error(message),{status:503,co
|
|
|
11
11
|
// Words worth checking: letters with inner hyphens; links, e-mails, abbreviations, words with digits
|
|
12
12
|
// and English contractions (isn't, couldn’t) are skipped.
|
|
13
13
|
export function words(text,{limit=20000}={}){
|
|
14
|
-
|
|
14
|
+
// An e-mail starts only where its run of characters starts: otherwise a long word is rescanned from every letter.
|
|
15
|
+
const seen=new Set(),clean=String(text).replace(/https?:\/\/\S+|www\.\S+|(?<![\w.+-])[\w.+-]+@[\w-]+\.[\w.-]+/g,' ');
|
|
15
16
|
for(const [w] of clean.matchAll(/(?<![\w-])[A-Za-zА-Яа-яЁё]+(?:[-'’][A-Za-zА-Яа-яЁё]+)*(?![\w-])/g))if(w.length>1&&!/['’]/.test(w)&&w!==w.toUpperCase()&&seen.size<limit)seen.add(w);
|
|
16
17
|
return [...seen];
|
|
17
18
|
}
|
package/src/worker.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {parentPort} from 'node:worker_threads';
|
|
2
|
+
import {check} from './check.mjs';
|
|
3
|
+
// One check per message, in a worker thread: a heavy text stalls only this worker, never the HTTP server.
|
|
4
|
+
// hunspell is asked for from the main thread: a process started here would stay a zombie when the worker
|
|
5
|
+
// is stopped on timeout, because only the thread that started a process collects its exit.
|
|
6
|
+
let seq=0;const asked=new Map();
|
|
7
|
+
const ask=(op,arg)=>new Promise((resolve,reject)=>{const id=++seq;asked.set(id,{resolve,reject});parentPort.postMessage({ask:{id,op,arg}});});
|
|
8
|
+
const spell={suggester:{suggest:word=>ask('suggest',word)},misspelled:list=>ask('misspelled',list)};
|
|
9
|
+
parentPort.on('message',async m=>{
|
|
10
|
+
if(m.answer){
|
|
11
|
+
const {id,value,error}=m.answer,wait=asked.get(id);asked.delete(id);
|
|
12
|
+
if(error)wait?.reject(Object.assign(new Error(error.message),error));else wait?.resolve(value);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try{parentPort.postMessage({result:await check(m.input,spell)});}
|
|
16
|
+
catch(e){parentPort.postMessage({error:{message:e.message,status:e.status,code:e.code}});}
|
|
17
|
+
});
|