@synopackageland/app 0.0.0-stage → 0.10.1
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 +219 -2
- package/dist/app.d.ts +45 -0
- package/dist/app.js +121 -0
- package/dist/channel.d.ts +13 -0
- package/dist/channel.js +37 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +65 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +77 -0
- package/dist/frames.d.ts +63 -0
- package/dist/frames.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/refusals.d.ts +1 -0
- package/dist/refusals.js +32 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 synopkgland
|
|
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
CHANGED
|
@@ -1,3 +1,220 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synopackageland/app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Node SDK for the backend of a Synology Package Land App written in
|
|
4
|
+
TypeScript or JavaScript. Node 18 or later (DSM Package Center's
|
|
5
|
+
`Node.js_v18`, `Node.js_v20`, `Node.js_v22`), ESM only, no runtime
|
|
6
|
+
dependencies.
|
|
7
|
+
|
|
8
|
+
The package's CGI is the synopkgland Go runtime (`bridge/cmd/runtime`). It
|
|
9
|
+
checks the request's origin, the DSM session, the entry gate, the method's
|
|
10
|
+
policy in `permissions.json` and the deadline, and only then starts the App,
|
|
11
|
+
as the package account, and hands it the call over the bridge channel
|
|
12
|
+
([`spec/bridge/v1`](../spec/bridge/v1/README.md)): JSON frames on stdin and
|
|
13
|
+
stdout, one process per call. The App never runs as root and never sees the
|
|
14
|
+
DSM session; what needs the session it asks of the runtime with
|
|
15
|
+
`ctx.runtime`.
|
|
16
|
+
|
|
17
|
+
## An App
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { App, AppError } from '@synopackageland/app'
|
|
23
|
+
|
|
24
|
+
const app = new App()
|
|
25
|
+
|
|
26
|
+
app.method('hello.greet', (ctx) => ({ message: `Hello, ${ctx.user}` }))
|
|
27
|
+
|
|
28
|
+
app.method('hello.save', (ctx, params: { note?: unknown } | null) => {
|
|
29
|
+
if (typeof params?.note !== 'string') throw new AppError('bad_request', 'note must be a string')
|
|
30
|
+
const notes = path.join(process.env.SYNOPKGLAND_DATA_DIR!, 'notes')
|
|
31
|
+
fs.mkdirSync(notes, { recursive: true, mode: 0o700 })
|
|
32
|
+
fs.writeFileSync(path.join(notes, `${ctx.requestId}.txt`), params.note, { flag: 'wx' })
|
|
33
|
+
return { bytes: Buffer.byteLength(params.note) }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await app.run()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The browser calls it with `@synopackageland/sdk` as any other method:
|
|
40
|
+
`{"method":"hello.greet"}` answers `{"success":true,"data":{"message":"Hello, alice"}}`.
|
|
41
|
+
|
|
42
|
+
The methods must be the ones in the package's `bridge.json` (`methods`);
|
|
43
|
+
the runtime registers its routes from there, and an App that says hello with
|
|
44
|
+
other methods is refused. Their policy is in `permissions.json`, as for a Go
|
|
45
|
+
App.
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### `new App(options?)`
|
|
50
|
+
|
|
51
|
+
- `options.log(error)`: where an unexpected error goes. The default writes it
|
|
52
|
+
to stderr, which DSM keeps in the package's log. The browser only sees
|
|
53
|
+
`internal`.
|
|
54
|
+
|
|
55
|
+
### `app.method(name, handler)`
|
|
56
|
+
|
|
57
|
+
Registers a method and returns the App. `name` is `<group>.<name>` in lower
|
|
58
|
+
case (`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`); a name registered twice
|
|
59
|
+
throws.
|
|
60
|
+
|
|
61
|
+
`handler(ctx, params)` returns the data, or a promise of it: any JSON value,
|
|
62
|
+
`undefined` is `null`. `params` is the request's `params` as the browser sent
|
|
63
|
+
it. The type parameter of `method<P>` only annotates it; check the value
|
|
64
|
+
before you use it.
|
|
65
|
+
|
|
66
|
+
### `app.run()`
|
|
67
|
+
|
|
68
|
+
Answers the one call the runtime sends on stdin, then exits. It refuses to
|
|
69
|
+
start as root (exit status 1) and when `SYNOPKGLAND_BRIDGE` is not `stdio`
|
|
70
|
+
(exit status 2), so it only runs under the runtime. While it serves, what
|
|
71
|
+
the App writes to `process.stdout`, `console.log` included, goes to stderr:
|
|
72
|
+
stdout is the channel. A child process started with `stdio: 'inherit'`
|
|
73
|
+
would still share it; give it `'ignore'` or pipes.
|
|
74
|
+
|
|
75
|
+
`node <entry> --describe` prints one JSON line instead,
|
|
76
|
+
`{"sdk":"node/…","versions":[1],"methods":[…]}`, so the package build can
|
|
77
|
+
check the methods against `bridge.json` and `permissions.json`.
|
|
78
|
+
`app.describe()` returns the same object.
|
|
79
|
+
|
|
80
|
+
### `app.serve(input, output)`
|
|
81
|
+
|
|
82
|
+
Answers one call on any pair of streams and resolves once the result is
|
|
83
|
+
written. `run` uses it on stdin and stdout; tests use it on a
|
|
84
|
+
`PassThrough` pair, playing the runtime's side of the channel.
|
|
85
|
+
|
|
86
|
+
### `Context`
|
|
87
|
+
|
|
88
|
+
| Member | |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `user` | The DSM user who called; empty for a public method |
|
|
91
|
+
| `admin` | Whether the user is a DSM administrator |
|
|
92
|
+
| `locale` | The first `Accept-Language` tag, or empty |
|
|
93
|
+
| `requestId` | The request's ID, 32 hex digits, also in the runtime's log |
|
|
94
|
+
| `deadline` | When the runtime ends the request, in `Date.now()` milliseconds |
|
|
95
|
+
| `deadlineMs` | The time that was left when the runtime sent the call, in milliseconds |
|
|
96
|
+
| `signal` | An `AbortSignal` aborted when the request is over; `signal.reason` is `'deadline'` or `'canceled'` |
|
|
97
|
+
| `cancelled` | Whether `signal` is aborted |
|
|
98
|
+
| `runtime(op, params?)` | Asks the runtime for a service on behalf of the caller; resolves to its result |
|
|
99
|
+
|
|
100
|
+
All of it comes from the runtime's verified caller. A `Context` belongs to
|
|
101
|
+
one call.
|
|
102
|
+
|
|
103
|
+
### Errors
|
|
104
|
+
|
|
105
|
+
`throw new AppError(code, message)` answers the browser
|
|
106
|
+
`{"success":false,"error":{"code":…,"message":…}}`. `code` is either
|
|
107
|
+
|
|
108
|
+
- the App's own code, `^[a-z][a-z0-9_]{0,63}$`, except the runtime's
|
|
109
|
+
reserved ones (`session_required`, `admin_required`,
|
|
110
|
+
`app_privilege_denied`, `auth_unavailable`, `unknown_method`,
|
|
111
|
+
`cross_origin`, `origin_required`, `request_timeout`, `request_canceled`,
|
|
112
|
+
`request_too_large`, `json_required`, `method_not_allowed`,
|
|
113
|
+
`app_unavailable`), or
|
|
114
|
+
- an upper-case refusal of
|
|
115
|
+
[`spec/protocol/v1/errors.json`](../spec/protocol/v1/errors.json), such as
|
|
116
|
+
`SCOPE_DENIED`, which `@synopackageland/sdk` translates.
|
|
117
|
+
|
|
118
|
+
Any other code throws `TypeError`, which, like any other error, answers the
|
|
119
|
+
browser `internal` and writes why to stderr. `message` is cut to 1024 bytes
|
|
120
|
+
of UTF-8. `AppError.badRequest(message)` is `new AppError('bad_request', message)`.
|
|
121
|
+
|
|
122
|
+
Anything else a handler throws, data JSON cannot encode (a `BigInt`, a
|
|
123
|
+
cycle), answers `internal`; the error goes to `log`, never to the browser.
|
|
124
|
+
|
|
125
|
+
### Services
|
|
126
|
+
|
|
127
|
+
`ctx.runtime(op, params)` sends the runtime a service request and resolves to
|
|
128
|
+
its result, or `null` when it has none. When the runtime refuses it rejects
|
|
129
|
+
with `ServiceError` (`op`, `code`, `message`): `unknown_service` for an op
|
|
130
|
+
the runtime does not offer, otherwise the service's code. A `ServiceError` a
|
|
131
|
+
handler does not catch reaches the browser when its code is a protocol v1
|
|
132
|
+
refusal, and is `internal` otherwise. An `op` that is not a service name, or
|
|
133
|
+
`params` JSON cannot encode, rejects with `TypeError` before anything is
|
|
134
|
+
sent.
|
|
135
|
+
|
|
136
|
+
The runtime offers the services of the capabilities in `bridge.json`
|
|
137
|
+
(`files.*`, `portal.status`, `delegate.*`, `bg.*`, `root.*`), listed with
|
|
138
|
+
their params in [`spec/bridge/v1`](../spec/bridge/v1/README.md#services).
|
|
139
|
+
They act for the verified caller, with the request's DSM session where they
|
|
140
|
+
need one, and end with the call.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
app.method('notes.list', (ctx) => ctx.runtime('files.list', { path: '/home/notes' }))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Transfers
|
|
147
|
+
|
|
148
|
+
A method `transfers` names in `bridge.json` is an HTTP upload or download.
|
|
149
|
+
Its `params` is `{query, upload: {path, size}}` for an upload, a spool file
|
|
150
|
+
to read, and `{query, download: {path}}` for a download, a spool file to
|
|
151
|
+
write before answering `{name, content_type?}`
|
|
152
|
+
([`spec/bridge/v1`](../spec/bridge/v1/README.md#transfers)).
|
|
153
|
+
|
|
154
|
+
### Deadline and cancel
|
|
155
|
+
|
|
156
|
+
The runtime owns the deadline. When it passes, or the browser goes away, the
|
|
157
|
+
runtime sends `cancel` and `ctx.signal` aborts; one second later it kills the
|
|
158
|
+
App's process group. Pass `ctx.signal` to `fetch`, `fs` and timers so that
|
|
159
|
+
they stop with the request:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
app.method('feed.fetch', async (ctx) => {
|
|
163
|
+
const reply = await fetch('https://example.com/feed.json', { signal: ctx.signal })
|
|
164
|
+
return reply.json()
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Once `signal` aborts, pending `ctx.runtime` calls, and later ones without
|
|
169
|
+
asking, reject with `ServiceError` code `request_canceled`. If the channel
|
|
170
|
+
ends early, `signal` aborts with `'canceled'`.
|
|
171
|
+
|
|
172
|
+
### Numbers
|
|
173
|
+
|
|
174
|
+
An App gets values, not JSON literals: `2.50` arrives as `2.5`, and integers
|
|
175
|
+
above 2^53 lose precision. Carry 64-bit IDs and sizes as strings.
|
|
176
|
+
|
|
177
|
+
## Testing an App
|
|
178
|
+
|
|
179
|
+
`serve` takes any streams, so a test can play the runtime:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { PassThrough } from 'node:stream'
|
|
183
|
+
import { createInterface } from 'node:readline'
|
|
184
|
+
|
|
185
|
+
const toApp = new PassThrough(), fromApp = new PassThrough()
|
|
186
|
+
const lines = createInterface({ input: fromApp })[Symbol.asyncIterator]()
|
|
187
|
+
const next = async () => JSON.parse((await lines.next()).value)
|
|
188
|
+
app.serve(toApp, fromApp)
|
|
189
|
+
toApp.write(JSON.stringify({ t: 'hello', versions: [1] }) + '\n')
|
|
190
|
+
await next() // {t: 'hello', version: 1, sdk: 'node/…', methods: [...]}
|
|
191
|
+
toApp.write(JSON.stringify({ t: 'call', id: '1', method: 'hello.greet', params: null,
|
|
192
|
+
ctx: { user: 'alice', admin: false, locale: 'en', request_id: '0'.repeat(32), deadline_ms: 60000 } }) + '\n')
|
|
193
|
+
await next() // {t: 'result', id: '1', ok: true, data: {message: 'Hello, alice'}}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## In a package
|
|
197
|
+
|
|
198
|
+
The App directory of the SPK is `/var/packages/<package>/target/app`. The
|
|
199
|
+
runtime starts `<entry>` there with
|
|
200
|
+
`/var/packages/<node.package>/target/usr/local/bin/node`, so the SPK depends
|
|
201
|
+
on that Package Center package (`install_dep_packages`). The SDK has no
|
|
202
|
+
dependencies: the package build copies `package.json` and `dist/` to
|
|
203
|
+
`app/node_modules/@synopackageland/app/`, or bundles the App and the SDK
|
|
204
|
+
into one file.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
npm ci
|
|
210
|
+
npm run generate # src/frames.ts and src/refusals.ts from spec/
|
|
211
|
+
npm test # type-checks src and test, then node:test
|
|
212
|
+
npm run build # dist/: ESM and .d.ts
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`src/frames.ts` (json-schema-to-typescript, from
|
|
216
|
+
`spec/bridge/v1/frames.schema.json`) and `src/refusals.ts` (from
|
|
217
|
+
`spec/protocol/v1/errors.json`) are generated and committed; a test fails
|
|
218
|
+
when they differ from `spec/`. `scripts/gate.sh` also runs the tests, the
|
|
219
|
+
bridge's golden runner and the hello example on Node 18 in Docker
|
|
220
|
+
(`scripts/node18-test.sh`).
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Writable, type Readable } from 'node:stream';
|
|
2
|
+
import { type Context } from './context.js';
|
|
3
|
+
/** The channel protocol version this SDK speaks. */
|
|
4
|
+
export declare const VERSION = 1;
|
|
5
|
+
/** The SDK's name in hello: node/<package version>. */
|
|
6
|
+
export declare const SDK = "node/0.10.1";
|
|
7
|
+
/**
|
|
8
|
+
* A method's implementation. params is the request's params as the browser
|
|
9
|
+
* sent them, not checked against P. The result is the data the browser gets,
|
|
10
|
+
* any JSON value; undefined is null.
|
|
11
|
+
*/
|
|
12
|
+
export type Handler<P = unknown> = (ctx: Context, params: P) => unknown;
|
|
13
|
+
export interface AppOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Where an unexpected error goes (stderr, the DSM log, by default); the
|
|
16
|
+
* browser only sees internal.
|
|
17
|
+
*/
|
|
18
|
+
log?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
/** The App: its methods, served on one channel per call. */
|
|
21
|
+
export declare class App {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(options?: AppOptions);
|
|
24
|
+
/** Registers a method; its name must be in the package's bridge.json. */
|
|
25
|
+
method<P = unknown>(name: string, handler: Handler<P>): this;
|
|
26
|
+
/**
|
|
27
|
+
* Answers the one call on a channel; resolves once the result is written,
|
|
28
|
+
* or the channel ended without a call.
|
|
29
|
+
*/
|
|
30
|
+
serve(input: Readable, output: Writable): Promise<void>;
|
|
31
|
+
/** What --describe prints: the App's methods, for the package build. */
|
|
32
|
+
describe(): {
|
|
33
|
+
sdk: string;
|
|
34
|
+
versions: number[];
|
|
35
|
+
methods: string[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Answers the one call the runtime sends on stdin and exits. Refuses to
|
|
39
|
+
* start as root (exit status 1) or outside the runtime, when
|
|
40
|
+
* SYNOPKGLAND_BRIDGE is not stdio (exit status 2). With --describe it
|
|
41
|
+
* prints describe() as one JSON line instead. While it serves, what the App
|
|
42
|
+
* writes to process.stdout (console.log) goes to stderr.
|
|
43
|
+
*/
|
|
44
|
+
run(): Promise<void>;
|
|
45
|
+
}
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import { Channel } from './channel.js';
|
|
3
|
+
import { Call } from './context.js';
|
|
4
|
+
import { browserError } from './errors.js';
|
|
5
|
+
/** The channel protocol version this SDK speaks. */
|
|
6
|
+
export const VERSION = 1;
|
|
7
|
+
/** The SDK's name in hello: node/<package version>. */
|
|
8
|
+
export const SDK = 'node/0.10.1';
|
|
9
|
+
const methodName = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
|
|
10
|
+
const internal = { code: 'internal', message: 'internal error' };
|
|
11
|
+
/** The App: its methods, served on one channel per call. */
|
|
12
|
+
export class App {
|
|
13
|
+
#methods = new Map();
|
|
14
|
+
#log;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.#log = options.log ?? ((error) => console.error(error));
|
|
17
|
+
}
|
|
18
|
+
/** Registers a method; its name must be in the package's bridge.json. */
|
|
19
|
+
method(name, handler) {
|
|
20
|
+
if (!methodName.test(name))
|
|
21
|
+
throw new TypeError(`invalid method name: ${name}`);
|
|
22
|
+
if (this.#methods.has(name))
|
|
23
|
+
throw new Error(`method registered twice: ${name}`);
|
|
24
|
+
this.#methods.set(name, handler);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Answers the one call on a channel; resolves once the result is written,
|
|
29
|
+
* or the channel ended without a call.
|
|
30
|
+
*/
|
|
31
|
+
async serve(input, output) {
|
|
32
|
+
const channel = new Channel(input, output);
|
|
33
|
+
const hello = await channel.recv();
|
|
34
|
+
if (hello?.t !== 'hello' || !Array.isArray(hello.versions) || !hello.versions.includes(VERSION))
|
|
35
|
+
return;
|
|
36
|
+
await channel.send({ t: 'hello', version: VERSION, sdk: SDK, methods: this.describe().methods });
|
|
37
|
+
const call = await channel.recv();
|
|
38
|
+
if (call?.t !== 'call')
|
|
39
|
+
return;
|
|
40
|
+
const id = call.id;
|
|
41
|
+
const state = new Call(channel, call.ctx);
|
|
42
|
+
void follow(channel, id, state);
|
|
43
|
+
const result = await this.#answer(id, call, state.ctx);
|
|
44
|
+
state.close();
|
|
45
|
+
await channel.write(result);
|
|
46
|
+
}
|
|
47
|
+
/** What --describe prints: the App's methods, for the package build. */
|
|
48
|
+
describe() {
|
|
49
|
+
return { sdk: SDK, versions: [VERSION], methods: [...this.#methods.keys()].sort() };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Answers the one call the runtime sends on stdin and exits. Refuses to
|
|
53
|
+
* start as root (exit status 1) or outside the runtime, when
|
|
54
|
+
* SYNOPKGLAND_BRIDGE is not stdio (exit status 2). With --describe it
|
|
55
|
+
* prints describe() as one JSON line instead. While it serves, what the App
|
|
56
|
+
* writes to process.stdout (console.log) goes to stderr.
|
|
57
|
+
*/
|
|
58
|
+
async run() {
|
|
59
|
+
if (process.argv.length === 3 && process.argv[2] === '--describe') {
|
|
60
|
+
process.stdout.write(JSON.stringify(this.describe()) + '\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const refusal = refuse();
|
|
64
|
+
if (refusal) {
|
|
65
|
+
process.stderr.write(`@synopackageland/app: ${refusal.message}\n`);
|
|
66
|
+
process.exitCode = refusal.status;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await this.serve(process.stdin, takeStdout());
|
|
70
|
+
// The result is written; stdin and whatever the handler left behind
|
|
71
|
+
// would keep the process alive.
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
// The encoded result frame. Data that JSON cannot encode fails here, before
|
|
75
|
+
// anything is written.
|
|
76
|
+
async #answer(id, call, ctx) {
|
|
77
|
+
const encode = (frame) => JSON.stringify(frame);
|
|
78
|
+
try {
|
|
79
|
+
const handler = this.#methods.get(call.method);
|
|
80
|
+
if (!handler)
|
|
81
|
+
throw new Error(`the runtime called ${String(call.method)}, which the App does not have`);
|
|
82
|
+
const data = await handler(ctx, call.params);
|
|
83
|
+
return encode({ t: 'result', id, ok: true, data: data === undefined ? null : data });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const error = browserError(e);
|
|
87
|
+
if (error)
|
|
88
|
+
return encode({ t: 'result', id, ok: false, error });
|
|
89
|
+
this.#log(e);
|
|
90
|
+
return encode({ t: 'result', id, ok: false, error: internal });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function refuse() {
|
|
95
|
+
if (process.geteuid?.() === 0)
|
|
96
|
+
return { message: 'refusing to run as root', status: 1 };
|
|
97
|
+
if (process.env.SYNOPKGLAND_BRIDGE !== 'stdio') {
|
|
98
|
+
return { message: 'the runtime starts an App with SYNOPKGLAND_BRIDGE=stdio; run --describe to list its methods', status: 2 };
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
// The channel's end of stdout. process.stdout itself, and so console.log,
|
|
103
|
+
// writes to stderr from now on: a stray line must not break the channel.
|
|
104
|
+
function takeStdout() {
|
|
105
|
+
const stdout = process.stdout;
|
|
106
|
+
const write = stdout.write.bind(stdout);
|
|
107
|
+
stdout.write = process.stderr.write.bind(process.stderr);
|
|
108
|
+
// A runtime that went away is seen as the end of the channel, not a crash.
|
|
109
|
+
stdout.on('error', () => { });
|
|
110
|
+
return new Writable({ write: (chunk, encoding, done) => write(chunk, encoding, done) });
|
|
111
|
+
}
|
|
112
|
+
// Reads the frames that arrive while the handler runs.
|
|
113
|
+
async function follow(channel, id, call) {
|
|
114
|
+
for (let f = await channel.recv(); f; f = await channel.recv()) {
|
|
115
|
+
if (f.t === 'svc_result')
|
|
116
|
+
call.answer(f);
|
|
117
|
+
else if (f.t === 'cancel' && f.id === id && (f.reason === 'deadline' || f.reason === 'canceled'))
|
|
118
|
+
call.cancel(f.reason);
|
|
119
|
+
}
|
|
120
|
+
call.close();
|
|
121
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream';
|
|
2
|
+
import type { Frame } from './frames.js';
|
|
3
|
+
export type Received = {
|
|
4
|
+
t: string;
|
|
5
|
+
[member: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
export declare class Channel {
|
|
8
|
+
#private;
|
|
9
|
+
constructor(input: Readable, output: Writable);
|
|
10
|
+
recv(): Promise<Received | null>;
|
|
11
|
+
send(frame: Frame): Promise<void>;
|
|
12
|
+
write(line: string): Promise<void>;
|
|
13
|
+
}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
// One channel to the runtime: a frame is one line of JSON (spec/bridge/v1
|
|
3
|
+
// README, Transport).
|
|
4
|
+
export class Channel {
|
|
5
|
+
#lines;
|
|
6
|
+
#output;
|
|
7
|
+
constructor(input, output) {
|
|
8
|
+
this.#lines = createInterface({ input, crlfDelay: Infinity })[Symbol.asyncIterator]();
|
|
9
|
+
this.#output = output;
|
|
10
|
+
// A runtime that went away is seen as the end of the channel, not a crash.
|
|
11
|
+
output.on('error', () => { });
|
|
12
|
+
}
|
|
13
|
+
// The next frame, or null once the channel ended or broke.
|
|
14
|
+
async recv() {
|
|
15
|
+
try {
|
|
16
|
+
const { value, done } = await this.#lines.next();
|
|
17
|
+
if (done)
|
|
18
|
+
return null;
|
|
19
|
+
const frame = JSON.parse(value);
|
|
20
|
+
return isReceived(frame) ? frame : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
send(frame) {
|
|
27
|
+
return this.write(JSON.stringify(frame));
|
|
28
|
+
}
|
|
29
|
+
// Writes an encoded frame; resolves once it is handed to the system, so
|
|
30
|
+
// that exiting after it does not drop it.
|
|
31
|
+
write(line) {
|
|
32
|
+
return new Promise((resolve) => this.#output.write(line + '\n', () => resolve()));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isReceived(frame) {
|
|
36
|
+
return typeof frame === 'object' && frame !== null && typeof frame.t === 'string';
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Channel, Received } from './channel.js';
|
|
2
|
+
import type { CallContext } from './frames.js';
|
|
3
|
+
/**
|
|
4
|
+
* Why the request is over: its deadline passed, or the browser went away
|
|
5
|
+
* (or the runtime closed the channel).
|
|
6
|
+
*/
|
|
7
|
+
export type CancelReason = 'deadline' | 'canceled';
|
|
8
|
+
/**
|
|
9
|
+
* What the runtime verified about the call, and the way back to it. A
|
|
10
|
+
* Context belongs to one call; do not keep it after the handler returns.
|
|
11
|
+
*/
|
|
12
|
+
export interface Context {
|
|
13
|
+
/** The DSM user who called; empty for a public method. */
|
|
14
|
+
readonly user: string;
|
|
15
|
+
/** Whether the user is a DSM administrator. */
|
|
16
|
+
readonly admin: boolean;
|
|
17
|
+
/** The first Accept-Language tag of the request, or empty. */
|
|
18
|
+
readonly locale: string;
|
|
19
|
+
/** The request's ID, 32 hex digits, also in the runtime's log. */
|
|
20
|
+
readonly requestId: string;
|
|
21
|
+
/** When the runtime ends the request, in Date.now() milliseconds. */
|
|
22
|
+
readonly deadline: number;
|
|
23
|
+
/** The time that was left when the runtime sent the call, in milliseconds. */
|
|
24
|
+
readonly deadlineMs: number;
|
|
25
|
+
/**
|
|
26
|
+
* Aborted when the request is over; its reason is a CancelReason. Pass it
|
|
27
|
+
* to fetch, fs and timers to stop with the request.
|
|
28
|
+
*/
|
|
29
|
+
readonly signal: AbortSignal;
|
|
30
|
+
/** Whether signal is aborted. */
|
|
31
|
+
readonly cancelled: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Asks the runtime for a service on behalf of the caller and resolves to
|
|
34
|
+
* its result (null when it has none). Rejects with ServiceError when the
|
|
35
|
+
* runtime refuses, with ServiceError request_canceled once the request is
|
|
36
|
+
* over (pending calls too), and with TypeError for an op that is not a service name
|
|
37
|
+
* or params JSON cannot encode.
|
|
38
|
+
*/
|
|
39
|
+
runtime(op: string, params?: unknown): Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
export declare class Call {
|
|
42
|
+
#private;
|
|
43
|
+
readonly ctx: Context;
|
|
44
|
+
constructor(channel: Channel, c: CallContext);
|
|
45
|
+
answer(frame: Received): void;
|
|
46
|
+
cancel(reason: CancelReason): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ServiceError } from './errors.js';
|
|
2
|
+
const opName = /^[a-z][a-z0-9_]*(\.[a-z][a-zA-Z0-9_]*)+$/;
|
|
3
|
+
// One call on its channel: the handler's Context and the services it waits for.
|
|
4
|
+
export class Call {
|
|
5
|
+
ctx;
|
|
6
|
+
#channel;
|
|
7
|
+
#abort = new AbortController();
|
|
8
|
+
#pending = new Map();
|
|
9
|
+
#next = 0;
|
|
10
|
+
constructor(channel, c) {
|
|
11
|
+
this.#channel = channel;
|
|
12
|
+
const signal = this.#abort.signal;
|
|
13
|
+
this.ctx = {
|
|
14
|
+
user: c.user,
|
|
15
|
+
admin: c.admin,
|
|
16
|
+
locale: c.locale,
|
|
17
|
+
requestId: c.request_id,
|
|
18
|
+
deadline: Date.now() + c.deadline_ms,
|
|
19
|
+
deadlineMs: c.deadline_ms,
|
|
20
|
+
signal,
|
|
21
|
+
get cancelled() {
|
|
22
|
+
return signal.aborted;
|
|
23
|
+
},
|
|
24
|
+
runtime: (op, params) => this.#runtime(op, params),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
#runtime(op, params = null) {
|
|
28
|
+
if (!opName.test(op))
|
|
29
|
+
return Promise.reject(new TypeError(`invalid service name: ${op}`));
|
|
30
|
+
if (this.#abort.signal.aborted)
|
|
31
|
+
return Promise.reject(closed(op));
|
|
32
|
+
const id = `s${++this.#next}`;
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
// send throws for params JSON cannot encode; the answer comes later.
|
|
35
|
+
void this.#channel.send({ t: 'svc', id, op, params });
|
|
36
|
+
this.#pending.set(id, { op, resolve, reject });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// Settles the service a svc_result answers.
|
|
40
|
+
answer(frame) {
|
|
41
|
+
const pending = this.#pending.get(frame.id);
|
|
42
|
+
if (!pending)
|
|
43
|
+
return;
|
|
44
|
+
this.#pending.delete(frame.id);
|
|
45
|
+
const error = frame.error;
|
|
46
|
+
if (frame.ok === true)
|
|
47
|
+
pending.resolve(frame.result ?? null);
|
|
48
|
+
else
|
|
49
|
+
pending.reject(new ServiceError(pending.op, error?.code ?? 'internal', error?.message ?? 'malformed service reply'));
|
|
50
|
+
}
|
|
51
|
+
// The request is over: what is pending ends, and nothing more is asked.
|
|
52
|
+
cancel(reason) {
|
|
53
|
+
this.#abort.abort(reason);
|
|
54
|
+
for (const p of this.#pending.values())
|
|
55
|
+
p.reject(closed(p.op));
|
|
56
|
+
this.#pending.clear();
|
|
57
|
+
}
|
|
58
|
+
// The channel ended, or the result is written; a first reason stays.
|
|
59
|
+
close() {
|
|
60
|
+
this.cancel('canceled');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function closed(op) {
|
|
64
|
+
return new ServiceError(op, 'request_canceled', 'the runtime closed the channel');
|
|
65
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const reservedCodes: ReadonlySet<string>;
|
|
2
|
+
export declare function isAppCode(code: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* An error the browser sees as {code, message}. code is the App's own code
|
|
5
|
+
* (^[a-z][a-z0-9_]{0,63}$, except the runtime's) or an upper-case refusal of
|
|
6
|
+
* spec/protocol/v1/errors.json, which @synopackageland/sdk translates; any
|
|
7
|
+
* other code throws TypeError. message is cut to 1024 bytes of UTF-8.
|
|
8
|
+
*/
|
|
9
|
+
export declare class AppError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
constructor(code: string, message: string);
|
|
12
|
+
/** AppError('bad_request', message). */
|
|
13
|
+
static badRequest(message: string): AppError;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A service the App asked for with ctx.runtime was refused. code is the
|
|
17
|
+
* runtime's: an api code such as unknown_service, or for executor-backed
|
|
18
|
+
* services a code of spec/protocol/v1/errors.json. Uncaught, an upper-case
|
|
19
|
+
* protocol v1 refusal reaches the browser as an AppError would; any other
|
|
20
|
+
* code is internal.
|
|
21
|
+
*/
|
|
22
|
+
export declare class ServiceError extends Error {
|
|
23
|
+
readonly code: string;
|
|
24
|
+
/** The service: the op given to ctx.runtime. */
|
|
25
|
+
readonly op: string;
|
|
26
|
+
constructor(op: string, code: string, message: string);
|
|
27
|
+
}
|
|
28
|
+
export declare function browserError(e: unknown): {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
} | undefined;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { protocolRefusals } from './refusals.js';
|
|
2
|
+
// The runtime's own refusals (spec/bridge/v1 README, Errors). An App that
|
|
3
|
+
// returned one would make the browser act on a decision the runtime did not
|
|
4
|
+
// take, such as signing in again.
|
|
5
|
+
export const reservedCodes = new Set([
|
|
6
|
+
'session_required', 'admin_required', 'app_privilege_denied', 'auth_unavailable',
|
|
7
|
+
'unknown_method', 'cross_origin', 'origin_required', 'request_timeout',
|
|
8
|
+
'request_canceled', 'request_too_large', 'json_required', 'method_not_allowed',
|
|
9
|
+
'app_unavailable',
|
|
10
|
+
]);
|
|
11
|
+
const appCode = /^[a-z][a-z0-9_]{0,63}$/;
|
|
12
|
+
const maxMessageBytes = 1024;
|
|
13
|
+
// isAppCode reports whether an App may return code: its own lower-case code,
|
|
14
|
+
// or a protocol v1 refusal it got from a service and passes on.
|
|
15
|
+
export function isAppCode(code) {
|
|
16
|
+
return (appCode.test(code) && !reservedCodes.has(code)) || protocolRefusals.has(code);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An error the browser sees as {code, message}. code is the App's own code
|
|
20
|
+
* (^[a-z][a-z0-9_]{0,63}$, except the runtime's) or an upper-case refusal of
|
|
21
|
+
* spec/protocol/v1/errors.json, which @synopackageland/sdk translates; any
|
|
22
|
+
* other code throws TypeError. message is cut to 1024 bytes of UTF-8.
|
|
23
|
+
*/
|
|
24
|
+
export class AppError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
if (!isAppCode(code))
|
|
28
|
+
throw new TypeError(`an App may not return error code ${JSON.stringify(code)}`);
|
|
29
|
+
super(truncate(message));
|
|
30
|
+
this.name = 'AppError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
/** AppError('bad_request', message). */
|
|
34
|
+
static badRequest(message) {
|
|
35
|
+
return new AppError('bad_request', message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function truncate(message) {
|
|
39
|
+
if (Buffer.byteLength(message) <= maxMessageBytes)
|
|
40
|
+
return message;
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
let end = 0;
|
|
43
|
+
for (const char of message) {
|
|
44
|
+
bytes += Buffer.byteLength(char);
|
|
45
|
+
if (bytes > maxMessageBytes)
|
|
46
|
+
break;
|
|
47
|
+
end += char.length;
|
|
48
|
+
}
|
|
49
|
+
return message.slice(0, end);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A service the App asked for with ctx.runtime was refused. code is the
|
|
53
|
+
* runtime's: an api code such as unknown_service, or for executor-backed
|
|
54
|
+
* services a code of spec/protocol/v1/errors.json. Uncaught, an upper-case
|
|
55
|
+
* protocol v1 refusal reaches the browser as an AppError would; any other
|
|
56
|
+
* code is internal.
|
|
57
|
+
*/
|
|
58
|
+
export class ServiceError extends Error {
|
|
59
|
+
code;
|
|
60
|
+
/** The service: the op given to ctx.runtime. */
|
|
61
|
+
op;
|
|
62
|
+
constructor(op, code, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'ServiceError';
|
|
65
|
+
this.code = code;
|
|
66
|
+
this.op = op;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The error the browser gets for an error the handler did not catch, or
|
|
70
|
+
// undefined when it gets internal.
|
|
71
|
+
export function browserError(e) {
|
|
72
|
+
if (e instanceof AppError)
|
|
73
|
+
return { code: e.code, message: e.message };
|
|
74
|
+
if (e instanceof ServiceError && protocolRefusals.has(e.code))
|
|
75
|
+
return { code: e.code, message: truncate(e.message) };
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type Frame = RuntimeHello | AppHello | Call | Cancel | Svc | SvcResult | Result;
|
|
2
|
+
export type Method = string;
|
|
3
|
+
export type Id = string;
|
|
4
|
+
export interface RuntimeHello {
|
|
5
|
+
t: "hello";
|
|
6
|
+
/**
|
|
7
|
+
* @minItems 1
|
|
8
|
+
*/
|
|
9
|
+
versions: [number, ...number[]];
|
|
10
|
+
}
|
|
11
|
+
export interface AppHello {
|
|
12
|
+
t: "hello";
|
|
13
|
+
version: number;
|
|
14
|
+
sdk: string;
|
|
15
|
+
methods: Method[];
|
|
16
|
+
}
|
|
17
|
+
export interface Call {
|
|
18
|
+
t: "call";
|
|
19
|
+
id: Id;
|
|
20
|
+
method: Method;
|
|
21
|
+
params: unknown;
|
|
22
|
+
ctx: CallContext;
|
|
23
|
+
}
|
|
24
|
+
export interface CallContext {
|
|
25
|
+
user: string;
|
|
26
|
+
admin: boolean;
|
|
27
|
+
locale: string;
|
|
28
|
+
request_id: string;
|
|
29
|
+
deadline_ms: number;
|
|
30
|
+
}
|
|
31
|
+
export interface Cancel {
|
|
32
|
+
t: "cancel";
|
|
33
|
+
id: Id;
|
|
34
|
+
reason: "deadline" | "canceled";
|
|
35
|
+
}
|
|
36
|
+
export interface Svc {
|
|
37
|
+
t: "svc";
|
|
38
|
+
id: Id;
|
|
39
|
+
op: string;
|
|
40
|
+
params: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface SvcResult {
|
|
43
|
+
t: "svc_result";
|
|
44
|
+
id: Id;
|
|
45
|
+
ok: boolean;
|
|
46
|
+
result?: unknown;
|
|
47
|
+
error?: SvcError;
|
|
48
|
+
}
|
|
49
|
+
export interface SvcError {
|
|
50
|
+
code: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
export interface Result {
|
|
54
|
+
t: "result";
|
|
55
|
+
id?: Id;
|
|
56
|
+
ok: boolean;
|
|
57
|
+
data?: unknown;
|
|
58
|
+
error?: ResultError;
|
|
59
|
+
}
|
|
60
|
+
export interface ResultError {
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
}
|
package/dist/frames.js
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Node SDK for Synology Package Land Apps (channel spec/bridge/v1).
|
|
2
|
+
//
|
|
3
|
+
// The Go runtime checks the request's origin, the DSM session, the entry
|
|
4
|
+
// gate, the route policy and the deadline; a handler here only sees calls
|
|
5
|
+
// that passed them, and asks the runtime for services with ctx.runtime. No
|
|
6
|
+
// dependencies; Node 18 or later.
|
|
7
|
+
export { App, SDK, VERSION } from './app.js';
|
|
8
|
+
export { AppError, ServiceError } from './errors.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const protocolRefusals: ReadonlySet<string>;
|
package/dist/refusals.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Code generated by scripts/generate.mjs from spec/protocol/v1/errors.json. DO NOT EDIT.
|
|
2
|
+
// The upper-case refusal codes of protocol v1. An App may return them, so the
|
|
3
|
+
// browser's SDK can translate them.
|
|
4
|
+
export const protocolRefusals = new Set([
|
|
5
|
+
'AUTH_DENIED',
|
|
6
|
+
'INELIGIBLE',
|
|
7
|
+
'APP_DENIED',
|
|
8
|
+
'ADMIN_REQUIRED',
|
|
9
|
+
'DELEGATION_DENIED',
|
|
10
|
+
'SCOPE_DENIED',
|
|
11
|
+
'POLICY_DENIED',
|
|
12
|
+
'PEER_DENIED',
|
|
13
|
+
'PERMISSION_DENIED',
|
|
14
|
+
'UNKNOWN_OP',
|
|
15
|
+
'INVALID_REQUEST',
|
|
16
|
+
'REQUEST_TOO_LARGE',
|
|
17
|
+
'EXECUTOR_PAUSED',
|
|
18
|
+
'STORAGE_ERROR',
|
|
19
|
+
'INTERNAL',
|
|
20
|
+
'WRITE_FAILED',
|
|
21
|
+
'READ_FAILED',
|
|
22
|
+
'DSM_FAILED',
|
|
23
|
+
'ROOT_FAILED',
|
|
24
|
+
'ROOT_TIMEOUT',
|
|
25
|
+
'ROOT_BUSY',
|
|
26
|
+
'ROOT_STAGING_FULL',
|
|
27
|
+
'ROOT_JOB_NOT_FOUND',
|
|
28
|
+
'PORTAL_FAILED',
|
|
29
|
+
'NO_SPACE',
|
|
30
|
+
'SCOPE_APPROVAL_STALE',
|
|
31
|
+
'IDENTITY_CHANGED',
|
|
32
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "Node SDK for Synology Package Land Apps: the backend methods behind the synopkgland Go runtime (channel spec/bridge/v1)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/synopackageland/syno-pkg-land-sdk/tree/main/node#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/synopackageland/syno-pkg-land-sdk/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/synopackageland/syno-pkg-land-sdk.git",
|
|
13
|
+
"directory": "node"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"prepack": "npm run build",
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
32
|
+
"generate": "node scripts/generate.mjs",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "rm -rf build && tsc && node --test --test-reporter=spec build/test/*.test.js"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "18.19.130",
|
|
38
|
+
"json-schema-to-typescript": "16.0.0",
|
|
39
|
+
"typescript": "5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|