document-rest 0.0.0 → 1.1.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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/bin.js +149 -0
- package/dist/index.cjs +110 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +109 -0
- package/package.json +105 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joseph Mearman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# document-rest
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ExaDev/documents.js/tree/main/packages/document-rest) [](https://www.npmjs.com/package/document-rest) [](https://www.npmjs.com/package/document-rest) [](https://github.com/ExaDev/documents.js/actions)
|
|
4
|
+
|
|
5
|
+
> A plain REST API server exposing [`document-operations`](../document-operations/README.md)'s document-conversion, `.odb`, metadata, and font tooling as JSON-over-HTTP `POST` routes, for a caller with no MCP client and no Node runtime of its own — a Kotlin/JVM app, a Python script, or any other language with an HTTP client.
|
|
6
|
+
|
|
7
|
+
`document-rest` adds no conversion or editing logic of its own — like [`document-mcp`](../document-mcp/README.md), it is a dispatch layer over [`document-operations`](../document-operations/README.md), the only difference being the transport: one `POST` route per operation instead of one MCP tool. `document-mcp`'s existing `--transport http` mode is not a substitute for this: it speaks MCP's own JSON-RPC protocol over a single `/mcp` path, which still needs an MCP client library to call — this package is for a caller that wants ordinary request/response JSON with no protocol of its own layered on top.
|
|
8
|
+
|
|
9
|
+
## Getting started
|
|
10
|
+
|
|
11
|
+
Run the server directly — no install step needed:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx document-rest --port 3100
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This binds a plain `node:http` listener to `127.0.0.1` (loopback only) on the given `--port` (default `3100`; `--port 0` asks the OS for a free port, reported on stderr once bound).
|
|
18
|
+
|
|
19
|
+
> **Security note:** this listener has no authentication and no Host/Origin allowlisting of its own — anyone who can reach it can call every operation, including ones that read and write arbitrary filesystem paths. It is safe by default only because it binds to loopback; whatever fronts it for remote access (a tunnel, a reverse proxy) is responsible for authenticating callers before traffic ever reaches this process. Matches [`document-mcp`'s own `--transport http` listener](../document-mcp/README.md#remote-transport-http), which carries the identical note for the identical reason.
|
|
20
|
+
|
|
21
|
+
### Standalone binary
|
|
22
|
+
|
|
23
|
+
Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, macOS, and Windows to that release's own GitHub Release assets — the entire server and its dependencies embedded in one file, needing no Node.js install or `npx` at all. For the "a caller with no Node runtime of its own" case this package exists for in the first place, this removes the last Node dependency too: download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases), run it directly (`chmod +x` on Linux/macOS first), and it takes the identical `--port` flag.
|
|
24
|
+
|
|
25
|
+
## API
|
|
26
|
+
|
|
27
|
+
**`GET /`** lists every available operation:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
curl http://127.0.0.1:3100/
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{ "operations": [{ "name": "convert_document", "title": "Convert document", "description": "..." }, ...] }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**`POST /<operationName>`** runs one operation: the request body is JSON, validated against that operation's own input schema, and the response is `{ "result": ... }` on success. Every operation `document-operations` defines is reachable this way — see [that package's own README](../document-operations/README.md#operations) for the full list of operation names and what each one does; the request/response shapes are identical to the equivalent MCP tool's own `structuredContent`, since both dispatch to the exact same `run()` function.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
curl -X POST http://127.0.0.1:3100/convert_document \
|
|
41
|
+
-H 'content-type: application/json' \
|
|
42
|
+
-d '{"source":{"path":"/tmp/report.docx"},"targetFormat":"markdown"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Errors
|
|
46
|
+
|
|
47
|
+
| Status | When |
|
|
48
|
+
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
49
|
+
| `400` | The request body is not valid JSON, fails the operation's own input schema (`{ error, issues }`, from Zod's `treeifyError`), or the operation itself threw (`{ error }`, the thrown message verbatim). |
|
|
50
|
+
| `404` | No operation exists with that name. |
|
|
51
|
+
| `405` | A method other than `POST` was used against a known operation route (`GET /` is the only exception). |
|
|
52
|
+
|
|
53
|
+
Two operations enrich their `400` body beyond a bare `{ error }`, mirroring `document-mcp`'s own `registerOperation` `mapError` hooks for the identical two typed errors: `odb_render_report`'s `OdbReportNotSpecifiedError` adds `availableReports` (every report name the `.odb` actually declares), and `odm_to_pdf`'s `OdmUnresolvedSectionError` adds `hrefs` (every chapter reference that failed to resolve).
|
|
54
|
+
|
|
55
|
+
## Getting started (development)
|
|
56
|
+
|
|
57
|
+
Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `package.json`).
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pnpm install
|
|
61
|
+
pnpm build # turbo -> tsdown -> dist/ (ESM + CJS + .d.ts)
|
|
62
|
+
pnpm typecheck # turbo -> tsc --noEmit, plus attw --pack
|
|
63
|
+
pnpm lint # turbo -> eslint . --fix --cache --max-warnings 0
|
|
64
|
+
pnpm test # turbo -> vitest run, driving a real ephemeral-port HTTP server through fetch()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Gotchas
|
|
68
|
+
|
|
69
|
+
- **No streaming, no chunked upload.** `readJsonBody` buffers the entire request body into memory before parsing it as JSON, matching every operation's own hybrid `bytesBase64`-or-`path` input convention (a large document is better supplied by filesystem `path`, read once by the operation itself, than base64-inflated over HTTP).
|
|
70
|
+
- **Abort on client disconnect.** Each request gets its own `AbortSignal`, wired to the underlying `IncomingMessage`'s `close` event (node:http carries no `AbortSignal` of its own) — a client that disconnects mid-conversion causes the operation's own signal-aware work (e.g. `convert_document`'s page-boundary cancellation) to stop at the next checkpoint rather than running to completion for a caller no longer listening.
|
|
71
|
+
|
|
72
|
+
## Contributing
|
|
73
|
+
|
|
74
|
+
Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the [monorepo root README](../../README.md#releases) for the release mechanism and [CONTRIBUTING.md](../../CONTRIBUTING.md) for the shared git hooks and history conventions. Work inside `packages/document-rest/`.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { DOCUMENT_OPERATIONS } from "document-operations";
|
|
4
|
+
import { OdbReportNotSpecifiedError, OdmUnresolvedSectionError } from "documents.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/server.ts
|
|
7
|
+
const OPERATIONS_BY_NAME = new Map(DOCUMENT_OPERATIONS.map((operation) => [operation.name, operation]));
|
|
8
|
+
const ERROR_MAPPERS = /* @__PURE__ */ new Map([["odb_render_report", (error) => {
|
|
9
|
+
if (!(error instanceof OdbReportNotSpecifiedError)) return void 0;
|
|
10
|
+
return {
|
|
11
|
+
status: 400,
|
|
12
|
+
body: {
|
|
13
|
+
error: error.message,
|
|
14
|
+
availableReports: error.availableReports
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}], ["odm_to_pdf", (error) => {
|
|
18
|
+
if (!(error instanceof OdmUnresolvedSectionError)) return void 0;
|
|
19
|
+
return {
|
|
20
|
+
status: 400,
|
|
21
|
+
body: {
|
|
22
|
+
error: `${error.message} Pass chaptersDir containing these files, or an explicit chapters override, for each href.`,
|
|
23
|
+
hrefs: error.hrefs
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}]]);
|
|
27
|
+
async function readJsonBody(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
30
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
31
|
+
if (text.length === 0) return {};
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
function sendJson(res, status, body) {
|
|
35
|
+
const text = JSON.stringify(body);
|
|
36
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
37
|
+
res.end(text);
|
|
38
|
+
}
|
|
39
|
+
function abortSignalFor(req) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
req.once("close", () => {
|
|
42
|
+
controller.abort();
|
|
43
|
+
});
|
|
44
|
+
return controller.signal;
|
|
45
|
+
}
|
|
46
|
+
async function handleOperationRequest(req, res, operation) {
|
|
47
|
+
let rawBody;
|
|
48
|
+
try {
|
|
49
|
+
rawBody = await readJsonBody(req);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
sendJson(res, 400, { error: `Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}` });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const parsed = operation.inputSchema.safeParse(rawBody);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
sendJson(res, 400, {
|
|
57
|
+
error: "Request body failed validation.",
|
|
58
|
+
issues: z.treeifyError(parsed.error)
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
sendJson(res, 200, { result: await operation.run(parsed.data, { signal: abortSignalFor(req) }) });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const mapped = ERROR_MAPPERS.get(operation.name)?.(error);
|
|
66
|
+
if (mapped !== void 0) {
|
|
67
|
+
sendJson(res, mapped.status, mapped.body);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function listOperations() {
|
|
74
|
+
return { operations: DOCUMENT_OPERATIONS.map((operation) => ({
|
|
75
|
+
name: operation.name,
|
|
76
|
+
title: operation.title,
|
|
77
|
+
description: operation.description
|
|
78
|
+
})) };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Builds a REST API server exposing every document-operations DocumentOperation as `POST /<name>`: the JSON request body is validated against the operation's own inputSchema, `run()` is called with the parsed input, and the result is returned as `{ result }`. `GET /` lists every available operation (name/title/description) for discovery. Never started -- `src/bin.ts` binds it to a port; a test binds it to an ephemeral one.
|
|
82
|
+
*/
|
|
83
|
+
function createRestServer() {
|
|
84
|
+
return createServer((req, res) => {
|
|
85
|
+
(async () => {
|
|
86
|
+
if (req.url === void 0 || req.method === void 0) {
|
|
87
|
+
sendJson(res, 400, { error: "Malformed request: no url or method." });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const url = new URL(req.url, "http://localhost");
|
|
91
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
92
|
+
sendJson(res, 200, listOperations());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const name = url.pathname.replace(/^\//, "");
|
|
96
|
+
const operation = OPERATIONS_BY_NAME.get(name);
|
|
97
|
+
if (operation === void 0) {
|
|
98
|
+
sendJson(res, 404, { error: `No operation named "${name}". GET / lists every available operation.` });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (req.method !== "POST") {
|
|
102
|
+
sendJson(res, 405, { error: `${name} only accepts POST, received ${req.method}.` });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await handleOperationRequest(req, res, operation);
|
|
106
|
+
})();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/cli.ts
|
|
111
|
+
const DEFAULT_PORT = 3100;
|
|
112
|
+
function readFlag(args, name) {
|
|
113
|
+
const prefix = `--${name}=`;
|
|
114
|
+
for (const [index, arg] of args.entries()) {
|
|
115
|
+
if (arg.startsWith(prefix)) return arg.slice(prefix.length);
|
|
116
|
+
if (arg === `--${name}`) {
|
|
117
|
+
const value = args[index + 1];
|
|
118
|
+
if (value === void 0) throw new Error(`--${name} requires a value`);
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function parsePort(raw) {
|
|
124
|
+
const port = Number.parseInt(raw, 10);
|
|
125
|
+
if (!Number.isInteger(port) || String(port) !== raw.trim() || port < 0 || port > 65535) throw new Error(`--port must be an integer between 0 and 65535, got "${raw}"`);
|
|
126
|
+
return port;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The real CLI entry logic, shared by both distribution shapes this package ships: `src/bin.ts` (the published npm `bin`, run under a real Node.js install) and `src/sea-entry.ts` (bundled into a Node single-executable application, which embeds its own Node runtime). Node's SEA feature runs only a CommonJS entry with no top-level await (see that file's own comment), so this function itself contains no top-level await of its own -- only `bin.ts` awaits calling it, at its own top level, which SEA never sees.
|
|
130
|
+
*
|
|
131
|
+
* Returns the listening `Server` rather than resolving `void` so a caller with a reason to stop it again (a test binding an ephemeral port for the duration of one assertion) can -- neither real distribution shape needs to, so both simply discard it.
|
|
132
|
+
*/
|
|
133
|
+
async function main() {
|
|
134
|
+
const portArg = readFlag(process.argv.slice(2), "port");
|
|
135
|
+
const port = portArg === void 0 ? DEFAULT_PORT : parsePort(portArg);
|
|
136
|
+
const server = createRestServer();
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
139
|
+
});
|
|
140
|
+
const address = server.address();
|
|
141
|
+
if (address === null || typeof address === "string") throw new Error("Expected the HTTP server to bind a TCP address, not a pipe or Unix socket");
|
|
142
|
+
console.error(`document-rest listening on http://127.0.0.1:${String(address.port)}`);
|
|
143
|
+
return server;
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/bin.ts
|
|
147
|
+
await main();
|
|
148
|
+
//#endregion
|
|
149
|
+
export {};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_http = require("node:http");
|
|
3
|
+
let document_operations = require("document-operations");
|
|
4
|
+
let documents_js = require("documents.js");
|
|
5
|
+
let zod = require("zod");
|
|
6
|
+
//#region src/server.ts
|
|
7
|
+
const OPERATIONS_BY_NAME = new Map(document_operations.DOCUMENT_OPERATIONS.map((operation) => [operation.name, operation]));
|
|
8
|
+
const ERROR_MAPPERS = /* @__PURE__ */ new Map([["odb_render_report", (error) => {
|
|
9
|
+
if (!(error instanceof documents_js.OdbReportNotSpecifiedError)) return void 0;
|
|
10
|
+
return {
|
|
11
|
+
status: 400,
|
|
12
|
+
body: {
|
|
13
|
+
error: error.message,
|
|
14
|
+
availableReports: error.availableReports
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}], ["odm_to_pdf", (error) => {
|
|
18
|
+
if (!(error instanceof documents_js.OdmUnresolvedSectionError)) return void 0;
|
|
19
|
+
return {
|
|
20
|
+
status: 400,
|
|
21
|
+
body: {
|
|
22
|
+
error: `${error.message} Pass chaptersDir containing these files, or an explicit chapters override, for each href.`,
|
|
23
|
+
hrefs: error.hrefs
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}]]);
|
|
27
|
+
async function readJsonBody(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
30
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
31
|
+
if (text.length === 0) return {};
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
function sendJson(res, status, body) {
|
|
35
|
+
const text = JSON.stringify(body);
|
|
36
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
37
|
+
res.end(text);
|
|
38
|
+
}
|
|
39
|
+
function abortSignalFor(req) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
req.once("close", () => {
|
|
42
|
+
controller.abort();
|
|
43
|
+
});
|
|
44
|
+
return controller.signal;
|
|
45
|
+
}
|
|
46
|
+
async function handleOperationRequest(req, res, operation) {
|
|
47
|
+
let rawBody;
|
|
48
|
+
try {
|
|
49
|
+
rawBody = await readJsonBody(req);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
sendJson(res, 400, { error: `Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}` });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const parsed = operation.inputSchema.safeParse(rawBody);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
sendJson(res, 400, {
|
|
57
|
+
error: "Request body failed validation.",
|
|
58
|
+
issues: zod.z.treeifyError(parsed.error)
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
sendJson(res, 200, { result: await operation.run(parsed.data, { signal: abortSignalFor(req) }) });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const mapped = ERROR_MAPPERS.get(operation.name)?.(error);
|
|
66
|
+
if (mapped !== void 0) {
|
|
67
|
+
sendJson(res, mapped.status, mapped.body);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function listOperations() {
|
|
74
|
+
return { operations: document_operations.DOCUMENT_OPERATIONS.map((operation) => ({
|
|
75
|
+
name: operation.name,
|
|
76
|
+
title: operation.title,
|
|
77
|
+
description: operation.description
|
|
78
|
+
})) };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Builds a REST API server exposing every document-operations DocumentOperation as `POST /<name>`: the JSON request body is validated against the operation's own inputSchema, `run()` is called with the parsed input, and the result is returned as `{ result }`. `GET /` lists every available operation (name/title/description) for discovery. Never started -- `src/bin.ts` binds it to a port; a test binds it to an ephemeral one.
|
|
82
|
+
*/
|
|
83
|
+
function createRestServer() {
|
|
84
|
+
return (0, node_http.createServer)((req, res) => {
|
|
85
|
+
(async () => {
|
|
86
|
+
if (req.url === void 0 || req.method === void 0) {
|
|
87
|
+
sendJson(res, 400, { error: "Malformed request: no url or method." });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const url = new URL(req.url, "http://localhost");
|
|
91
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
92
|
+
sendJson(res, 200, listOperations());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const name = url.pathname.replace(/^\//, "");
|
|
96
|
+
const operation = OPERATIONS_BY_NAME.get(name);
|
|
97
|
+
if (operation === void 0) {
|
|
98
|
+
sendJson(res, 404, { error: `No operation named "${name}". GET / lists every available operation.` });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (req.method !== "POST") {
|
|
102
|
+
sendJson(res, 405, { error: `${name} only accepts POST, received ${req.method}.` });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await handleOperationRequest(req, res, operation);
|
|
106
|
+
})();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
exports.createRestServer = createRestServer;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Server } from "node:http";
|
|
2
|
+
//#region src/server.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Builds a REST API server exposing every document-operations DocumentOperation as `POST /<name>`: the JSON request body is validated against the operation's own inputSchema, `run()` is called with the parsed input, and the result is returned as `{ result }`. `GET /` lists every available operation (name/title/description) for discovery. Never started -- `src/bin.ts` binds it to a port; a test binds it to an ephemeral one.
|
|
5
|
+
*/
|
|
6
|
+
declare function createRestServer(): Server;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { createRestServer };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Server } from "node:http";
|
|
2
|
+
//#region src/server.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Builds a REST API server exposing every document-operations DocumentOperation as `POST /<name>`: the JSON request body is validated against the operation's own inputSchema, `run()` is called with the parsed input, and the result is returned as `{ result }`. `GET /` lists every available operation (name/title/description) for discovery. Never started -- `src/bin.ts` binds it to a port; a test binds it to an ephemeral one.
|
|
5
|
+
*/
|
|
6
|
+
declare function createRestServer(): Server;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { createRestServer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { DOCUMENT_OPERATIONS } from "document-operations";
|
|
3
|
+
import { OdbReportNotSpecifiedError, OdmUnresolvedSectionError } from "documents.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/server.ts
|
|
6
|
+
const OPERATIONS_BY_NAME = new Map(DOCUMENT_OPERATIONS.map((operation) => [operation.name, operation]));
|
|
7
|
+
const ERROR_MAPPERS = /* @__PURE__ */ new Map([["odb_render_report", (error) => {
|
|
8
|
+
if (!(error instanceof OdbReportNotSpecifiedError)) return void 0;
|
|
9
|
+
return {
|
|
10
|
+
status: 400,
|
|
11
|
+
body: {
|
|
12
|
+
error: error.message,
|
|
13
|
+
availableReports: error.availableReports
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}], ["odm_to_pdf", (error) => {
|
|
17
|
+
if (!(error instanceof OdmUnresolvedSectionError)) return void 0;
|
|
18
|
+
return {
|
|
19
|
+
status: 400,
|
|
20
|
+
body: {
|
|
21
|
+
error: `${error.message} Pass chaptersDir containing these files, or an explicit chapters override, for each href.`,
|
|
22
|
+
hrefs: error.hrefs
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}]]);
|
|
26
|
+
async function readJsonBody(req) {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
29
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
30
|
+
if (text.length === 0) return {};
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
function sendJson(res, status, body) {
|
|
34
|
+
const text = JSON.stringify(body);
|
|
35
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
36
|
+
res.end(text);
|
|
37
|
+
}
|
|
38
|
+
function abortSignalFor(req) {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
req.once("close", () => {
|
|
41
|
+
controller.abort();
|
|
42
|
+
});
|
|
43
|
+
return controller.signal;
|
|
44
|
+
}
|
|
45
|
+
async function handleOperationRequest(req, res, operation) {
|
|
46
|
+
let rawBody;
|
|
47
|
+
try {
|
|
48
|
+
rawBody = await readJsonBody(req);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
sendJson(res, 400, { error: `Request body is not valid JSON: ${error instanceof Error ? error.message : String(error)}` });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const parsed = operation.inputSchema.safeParse(rawBody);
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
sendJson(res, 400, {
|
|
56
|
+
error: "Request body failed validation.",
|
|
57
|
+
issues: z.treeifyError(parsed.error)
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
sendJson(res, 200, { result: await operation.run(parsed.data, { signal: abortSignalFor(req) }) });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const mapped = ERROR_MAPPERS.get(operation.name)?.(error);
|
|
65
|
+
if (mapped !== void 0) {
|
|
66
|
+
sendJson(res, mapped.status, mapped.body);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function listOperations() {
|
|
73
|
+
return { operations: DOCUMENT_OPERATIONS.map((operation) => ({
|
|
74
|
+
name: operation.name,
|
|
75
|
+
title: operation.title,
|
|
76
|
+
description: operation.description
|
|
77
|
+
})) };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Builds a REST API server exposing every document-operations DocumentOperation as `POST /<name>`: the JSON request body is validated against the operation's own inputSchema, `run()` is called with the parsed input, and the result is returned as `{ result }`. `GET /` lists every available operation (name/title/description) for discovery. Never started -- `src/bin.ts` binds it to a port; a test binds it to an ephemeral one.
|
|
81
|
+
*/
|
|
82
|
+
function createRestServer() {
|
|
83
|
+
return createServer((req, res) => {
|
|
84
|
+
(async () => {
|
|
85
|
+
if (req.url === void 0 || req.method === void 0) {
|
|
86
|
+
sendJson(res, 400, { error: "Malformed request: no url or method." });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const url = new URL(req.url, "http://localhost");
|
|
90
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
91
|
+
sendJson(res, 200, listOperations());
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const name = url.pathname.replace(/^\//, "");
|
|
95
|
+
const operation = OPERATIONS_BY_NAME.get(name);
|
|
96
|
+
if (operation === void 0) {
|
|
97
|
+
sendJson(res, 404, { error: `No operation named "${name}". GET / lists every available operation.` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (req.method !== "POST") {
|
|
101
|
+
sendJson(res, 405, { error: `${name} only accepts POST, received ${req.method}.` });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await handleOperationRequest(req, res, operation);
|
|
105
|
+
})();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { createRestServer };
|
package/package.json
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "document-rest",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A plain REST API server exposing document-operations' conversion, editing, inspection, and .odb operations as JSON-over-HTTP endpoints -- one POST route per operation, for a caller with no MCP client and no Node runtime of their own.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ExaDev/documents.js.git",
|
|
9
|
+
"directory": "packages/document-rest"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ExaDev/documents.js/tree/main/packages/document-rest",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ExaDev/documents.js/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"document-rest": "./dist/bin.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": {
|
|
21
|
+
"import": "./dist/index.d.ts",
|
|
22
|
+
"require": "./dist/index.d.cts"
|
|
23
|
+
},
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
},
|
|
27
|
+
"./*": {
|
|
28
|
+
"types": {
|
|
29
|
+
"import": "./dist/*.d.ts",
|
|
30
|
+
"require": "./dist/*.d.cts"
|
|
31
|
+
},
|
|
32
|
+
"import": "./dist/*.js",
|
|
33
|
+
"require": "./dist/*.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.cjs",
|
|
37
|
+
"module": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true,
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "turbo run _build",
|
|
53
|
+
"_build": "tsdown",
|
|
54
|
+
"prepublishOnly": "pnpm run lint && pnpm run typecheck && tsdown && publint && attw --pack",
|
|
55
|
+
"lint": "turbo run _lint",
|
|
56
|
+
"_lint": "eslint . --fix --cache --max-warnings 0",
|
|
57
|
+
"typecheck": "turbo run _typecheck _typecheck:attw",
|
|
58
|
+
"_typecheck": "tsc --noEmit",
|
|
59
|
+
"_typecheck:attw": "attw --pack",
|
|
60
|
+
"test": "turbo run _test",
|
|
61
|
+
"_test": "vitest run --project unit",
|
|
62
|
+
"test:watch": "vitest",
|
|
63
|
+
"test:coverage": "turbo run _test:coverage",
|
|
64
|
+
"_test:coverage": "vitest run --project unit --coverage",
|
|
65
|
+
"test:mutation": "turbo run _test:mutation",
|
|
66
|
+
"_test:mutation": "stryker run stryker.config.ts",
|
|
67
|
+
"test:smoke": "turbo run _test:smoke",
|
|
68
|
+
"_test:smoke": "vitest run --project smoke",
|
|
69
|
+
"prepare": "husky",
|
|
70
|
+
"release": "semantic-release"
|
|
71
|
+
},
|
|
72
|
+
"keywords": [
|
|
73
|
+
"rest",
|
|
74
|
+
"http",
|
|
75
|
+
"api",
|
|
76
|
+
"docx",
|
|
77
|
+
"pptx",
|
|
78
|
+
"pdf",
|
|
79
|
+
"odt",
|
|
80
|
+
"odp",
|
|
81
|
+
"ods",
|
|
82
|
+
"odg",
|
|
83
|
+
"odf",
|
|
84
|
+
"odb",
|
|
85
|
+
"document-conversion"
|
|
86
|
+
],
|
|
87
|
+
"license": "MIT",
|
|
88
|
+
"packageManager": "pnpm@11.6.0",
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"document-operations": "1.1.0",
|
|
91
|
+
"documents.js": "7.20.1",
|
|
92
|
+
"zod": "4.4.3"
|
|
93
|
+
},
|
|
94
|
+
"devDependencies": {
|
|
95
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
96
|
+
"@types/node": "26.2.0",
|
|
97
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
98
|
+
"eslint": "10.8.1",
|
|
99
|
+
"husky": "9.1.7",
|
|
100
|
+
"odf.js": "7.25.1",
|
|
101
|
+
"publint": "0.3.22",
|
|
102
|
+
"semantic-release": "25.0.9",
|
|
103
|
+
"tsdown": "0.22.14",
|
|
104
|
+
"turbo": "2.10.8",
|
|
105
|
+
"typescript": "6.0.3",
|
|
106
|
+
"vitest": "4.1.11"
|
|
107
|
+
}
|
|
5
108
|
}
|