@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 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
- # Temporary Holding Version
1
+ # @synopackageland/app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ // Code generated by scripts/generate.mjs from spec/bridge/v1/frames.schema.json. DO NOT EDIT.
2
+ export {};
@@ -0,0 +1,3 @@
1
+ export { App, SDK, VERSION, type AppOptions, type Handler } from './app.js';
2
+ export type { CancelReason, Context } from './context.js';
3
+ export { AppError, ServiceError } from './errors.js';
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>;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }