@synopackageland/cli 0.39.0 → 0.40.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.0 (2026-10-29)
4
+
5
+ - `daemon.backend: true` runs the App's own native backend
6
+ (`ui/cgi/api.cgi`) as the package daemon with `args`, instead of a separate
7
+ `exec`. The binary keeps the route audit and build digests of the CGI
8
+ build. `targets` becomes optional; with `daemon.node` the CLI writes its
9
+ Node launcher and passes `SYNOPKGLAND_NODE` and
10
+ `SYNOPKGLAND_DAEMON_PAYLOAD` so the backend can start Node children itself.
11
+ It needs an SDK whose daemon controller supports backend daemons
12
+ (`STANDALONE_DAEMON_BUILD_FAILED` otherwise) and cannot be combined with
13
+ `identity_gateway`. `inspect` verifies the `backend` config and the
14
+ backend's mode.
15
+ - New skill reference `daemon-mode`: when a resident backend daemon pays off
16
+ and when to stay with the CGI, with a decision table, the factors to
17
+ measure and decision steps.
18
+ - The route audit accepts `api.ServeIngress` (SDK) like `cgi.Serve`: called
19
+ directly, and never with a private or raw HTTP handler.
20
+ - Pin templates 0.35.0 and its SDK/App 0.37.0 defaults. `daemon.backend` and
21
+ `api.ServeIngress` need SDK/App 0.37.0.
22
+
3
23
  ## 0.39.0 (2026-10-29)
4
24
 
5
25
  - Add schema-2 `dependencies.packages` for DSM package dependencies independent
package/README.md CHANGED
@@ -78,7 +78,7 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
78
78
  packages. Private mirrors can use explicitly configured Go authentication and
79
79
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
80
80
 
81
- CLI 0.38.0 uses templates ~0.34.0, whose default SDK pin is 0.36.0. Compose import and SDK defaults
81
+ CLI 0.40.0 uses templates ~0.35.0, whose default SDK pin is 0.37.0. Compose import and SDK defaults
82
82
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
83
83
  Every App method must be declared in the permissions table. Dynamic names and
84
84
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
@@ -89,8 +89,8 @@ unimported development commands are excluded. Use
89
89
  authentication. See [App entry and registration](docs/app-entry.md).
90
90
 
91
91
  `init` and `add` read their templates from the locally installed
92
- `@synopackageland/templates`. CLI 0.38.0 depends on `~0.34.0`: npm installation or
93
- update can select the latest templates patch in the 0.34 minor line. Templates
92
+ `@synopackageland/templates`. CLI 0.40.0 depends on `~0.35.0`: npm installation or
93
+ update can select the latest templates patch in the 0.35 minor line. Templates
94
94
  patches need only a templates release; update the npm installation to receive them.
95
95
  The CLI never fetches templates at runtime, so offline use works after installation;
96
96
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -600,6 +600,10 @@ func main() {
600
600
  if !direct[selector] {
601
601
  problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
602
602
  }
603
+ case "ServeIngress":
604
+ if !direct[selector] {
605
+ problem(selector.Pos(), "ingress constructor aliases cannot be audited")
606
+ }
603
607
  }
604
608
  }
605
609
  if module == "net/http" {
@@ -659,6 +663,10 @@ func main() {
659
663
  if module == "net/http/cgi" && selector.Sel.Name == "Serve" && len(call.Args) > 0 && reusesBoundary(call.Args[0], src, sources, safe, map[ast.Node]bool{}) {
660
664
  problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
661
665
  }
666
+ // The daemon ingress serves the same browser requests as cgi.Serve.
667
+ if module == sdkModule+"/api" && selector.Sel.Name == "ServeIngress" && len(call.Args) > 1 && reusesBoundary(call.Args[1], src, sources, safe, map[ast.Node]bool{}) {
668
+ problem(call.Pos(), "private or raw HTTP handler reused outside its SDK boundary")
669
+ }
662
670
  if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
663
671
  out.EntryGate = true
664
672
  }
package/dist/skill.js CHANGED
@@ -57,6 +57,11 @@ const SKILL_CATALOG = [
57
57
  file: "reference/backup.md",
58
58
  when: "Change or opt out of Hyper Backup application backup",
59
59
  },
60
+ {
61
+ id: "daemon-mode",
62
+ file: "reference/daemon-mode.md",
63
+ when: "Decide whether the backend should run as a resident daemon (daemon.backend)",
64
+ },
60
65
  { id: "deploy", file: "reference/deploy.md", when: "Deploy to a NAS" },
61
66
  {
62
67
  id: "diagnose",
@@ -7,8 +7,13 @@ import type { StandaloneError, StandaloneManifest } from "./manifest.js";
7
7
  export interface Daemon {
8
8
  /** Optional bounded recovery; omitted means off. SDK 0.26.0 or later. */
9
9
  restart?: DaemonRestart;
10
- /** Fixed foreground executable relative to daemon/payload. */
10
+ /** Fixed foreground executable relative to daemon/payload; empty for a backend daemon. */
11
11
  exec: string;
12
+ /**
13
+ * The App's own audited backend (ui/cgi/api.cgi) runs as the daemon with
14
+ * `args`; `node`, if set, is then only a runtime for the backend's children.
15
+ */
16
+ backend?: true;
12
17
  /** Architecture-specific project directories, copied without links. */
13
18
  targets: Partial<Record<NativeArchFamily, string>>;
14
19
  args: string[];
@@ -56,6 +61,10 @@ export declare function daemonConfig(manifest: StandaloneManifest): string;
56
61
  * launcher with its entry first, and ingress names the socket to listen on.
57
62
  */
58
63
  export declare function runnableDaemon(pkg: string, daemon: Daemon): Daemon;
64
+ /** What a backend daemon learns about the Node runtime it may start children with. */
65
+ export declare const backendNodeEnv: () => Record<string, string>;
66
+ /** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
67
+ export declare function requireBackendDaemonSource(source: string, daemon: Daemon | undefined): void;
59
68
  export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
60
69
  export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
61
70
  /** Copies a bounded, link-free package-account payload tree. */
@@ -1,6 +1,6 @@
1
1
  import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
2
2
  import { goChildEnv } from "../runtime/go-env.js";
3
- import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, writeFileSync, } from "node:fs";
3
+ import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
6
6
  import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
@@ -35,6 +35,7 @@ exec "$node" "$@"
35
35
  }
36
36
  const fields = new Set([
37
37
  "exec",
38
+ "backend",
38
39
  "targets",
39
40
  "args",
40
41
  "env",
@@ -69,19 +70,25 @@ export function readDaemon(value, root, arches, fail) {
69
70
  const invalid = () => {
70
71
  fail({
71
72
  code: "STANDALONE_DAEMON_INVALID",
72
- message: "daemon accepts only fixed exec, targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
73
+ message: "daemon accepts only fixed exec (or backend: true), targets, args, env and timeouts; paths must be inside the project, and the environment does not accept loader/shell/system variables.",
73
74
  });
74
75
  return undefined;
75
76
  };
76
77
  if (!record(value) || Object.keys(value).some((k) => !fields.has(k)))
77
78
  return invalid();
78
- if (!(value.node === undefined ? relativeExec(value.exec) : relativeEntry(value.exec)))
79
+ const backend = value.backend === true;
80
+ if (value.backend !== undefined && !backend)
79
81
  return invalid();
80
- if (!record(value.targets) ||
81
- Object.keys(value.targets).length !== arches.length)
82
+ if (backend ? value.exec !== undefined : !(value.node === undefined ? relativeExec(value.exec) : relativeEntry(value.exec)))
83
+ return invalid();
84
+ // A backend daemon needs a payload only for its children's files.
85
+ const payload = !backend || value.targets !== undefined;
86
+ if (payload &&
87
+ (!record(value.targets) ||
88
+ Object.keys(value.targets).length !== arches.length))
82
89
  return invalid();
83
90
  const targets = {};
84
- for (const arch of arches) {
91
+ for (const arch of payload ? arches : []) {
85
92
  const path = value.targets[arch];
86
93
  if (typeof path !== "string" ||
87
94
  !path.trim() ||
@@ -123,8 +130,11 @@ export function readDaemon(value, root, arches, fail) {
123
130
  return undefined;
124
131
  if (ingress && Object.keys(env).some((key) => key in ingressEnv("", ingress)))
125
132
  return invalid();
133
+ if (backend && node && Object.keys(env).some((key) => key in backendNodeEnv()))
134
+ return invalid();
126
135
  return {
127
- exec: value.exec,
136
+ exec: backend ? "" : value.exec,
137
+ ...(backend ? { backend: true } : {}),
128
138
  targets,
129
139
  args,
130
140
  ...(uninstall ? { uninstall_args: uninstall } : {}),
@@ -171,7 +181,7 @@ export function daemonConfig(manifest) {
171
181
  : runnableDaemon(manifest.package, manifest.daemon);
172
182
  return (JSON.stringify({
173
183
  package: manifest.package,
174
- exec,
184
+ ...(!manifest.compose && manifest.daemon?.backend ? { backend: true } : { exec }),
175
185
  args,
176
186
  ...(uninstall_args ? { uninstall_args } : {}),
177
187
  ...(restart ? { restart } : {}),
@@ -187,8 +197,12 @@ export function daemonConfig(manifest) {
187
197
  * launcher with its entry first, and ingress names the socket to listen on.
188
198
  */
189
199
  export function runnableDaemon(pkg, daemon) {
190
- const entry = daemon.node && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
191
- const env = daemon.ingress ? { ...daemon.env, ...ingressEnv(pkg, daemon.ingress) } : daemon.env;
200
+ const entry = daemon.node && !daemon.backend && [`\${SYNOPKG_PKGDEST}/daemon/payload/${daemon.exec}`];
201
+ const env = {
202
+ ...daemon.env,
203
+ ...(daemon.ingress ? ingressEnv(pkg, daemon.ingress) : {}),
204
+ ...(daemon.backend && daemon.node ? backendNodeEnv() : {}),
205
+ };
192
206
  return {
193
207
  ...daemon,
194
208
  ...(entry ? { exec: NODE_LAUNCHER, args: [...entry, ...daemon.args] } : {}),
@@ -196,6 +210,19 @@ export function runnableDaemon(pkg, daemon) {
196
210
  env: Object.fromEntries(Object.keys(env).sort().map((key) => [key, env[key]])),
197
211
  };
198
212
  }
213
+ /** What a backend daemon learns about the Node runtime it may start children with. */
214
+ export const backendNodeEnv = () => ({
215
+ SYNOPKGLAND_DAEMON_PAYLOAD: "${SYNOPKG_PKGDEST}/daemon/payload",
216
+ SYNOPKGLAND_NODE: `\${SYNOPKG_PKGDEST}/daemon/payload/${NODE_LAUNCHER}`,
217
+ });
218
+ /** daemonctl runs ui/cgi/api.cgi (`"backend": true`) from SDK/App 0.37.0. */
219
+ export function requireBackendDaemonSource(source, daemon) {
220
+ if (!daemon?.backend)
221
+ return;
222
+ const main = join(source, "daemonctl/main.go");
223
+ if (!existsSync(main) || !readFileSync(main, "utf8").includes("backendExecutable"))
224
+ throw new StandaloneBuildError("STANDALONE_DAEMON_BUILD_FAILED", "daemon.backend needs an SDK whose daemon controller can run the backend (SDK/App 0.37.0 or later); upgrade the App SDK.");
225
+ }
199
226
  export async function buildDaemonControl(manifest, arch, work, run) {
200
227
  if (!manifest.daemon && !(manifest.compose && manifest.identityGateway))
201
228
  return undefined;
@@ -203,6 +230,7 @@ export async function buildDaemonControl(manifest, arch, work, run) {
203
230
  const sdk = daemonSdkVersion(manifest);
204
231
  const source = runtimeBudgetSource(sdkSource(sdk).goDir, manifest);
205
232
  requireDaemonRestartSource(source, sdk, manifest.daemon?.restart);
233
+ requireBackendDaemonSource(source, manifest.daemon);
206
234
  const result = await run("go", [
207
235
  "build",
208
236
  "-trimpath",
@@ -233,16 +261,16 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
233
261
  copyPayloadTree(manifest.root, source, join(target, "payload"));
234
262
  else
235
263
  mkdirSync(join(target, "payload"), { recursive: true });
236
- if (!manifest.compose && manifest.daemon?.node) {
264
+ if (!manifest.compose && manifest.daemon?.backend) {
265
+ // The CLI installs the backend itself at ui/cgi/api.cgi.
266
+ if (manifest.daemon.node)
267
+ writeNodeLauncher(source, target, manifest.daemon.node);
268
+ }
269
+ else if (!manifest.compose && manifest.daemon?.node) {
237
270
  const exec = join(source, daemon.exec);
238
271
  if (!existsSync(exec) || !lstatSync(exec).isFile())
239
272
  throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `daemon.exec must name the Node entry file inside the payload: ${exec}`);
240
- if (existsSync(join(source, NODE_LAUNCHER.split("/")[0])))
241
- throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
242
- mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
243
- chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
244
- writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(manifest.daemon.node));
245
- chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
273
+ writeNodeLauncher(source, target, manifest.daemon.node);
246
274
  }
247
275
  else if (!manifest.compose) {
248
276
  const exec = join(source, daemon.exec);
@@ -257,6 +285,14 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
257
285
  mode: 0o644,
258
286
  });
259
287
  }
288
+ function writeNodeLauncher(source, target, node) {
289
+ if (source && existsSync(join(source, NODE_LAUNCHER.split("/")[0])))
290
+ throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", `The daemon payload must not contain ${NODE_LAUNCHER.split("/")[0]}/; the CLI writes its Node launcher there.`);
291
+ mkdirSync(join(target, "payload", dirname(NODE_LAUNCHER)), { recursive: true });
292
+ chmodSync(join(target, "payload", dirname(NODE_LAUNCHER)), 0o755);
293
+ writeFileSync(join(target, "payload", NODE_LAUNCHER), renderNodeLauncher(node));
294
+ chmodSync(join(target, "payload", NODE_LAUNCHER), 0o755);
295
+ }
260
296
  /** Copies a bounded, link-free package-account payload tree. */
261
297
  export function copyPayloadTree(root, source, target) {
262
298
  const invalid = (message) => {
@@ -24,7 +24,7 @@ import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.
24
24
  import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
25
25
  import { ELF_MACHINE } from "./binary.js";
26
26
  import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
27
- import { daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
27
+ import { backendNodeEnv, daemonConfig, readDaemon, readLifecycleTimeouts, renderNodeLauncher, nodeDependency, NODE_LAUNCHER } from "./daemon.js";
28
28
  import { DAEMON_INGRESS_CONFIG, daemonIngressMinDsmProblem, readDaemonIngress, renderDaemonIngressConfig } from "./daemon-ingress.js";
29
29
  import { daemonRestartSdkProblem, readDaemonRestart } from "./daemon-restart.js";
30
30
  import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
@@ -970,6 +970,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
970
970
  Object.keys(config).some((key) => ![
971
971
  "package",
972
972
  "exec",
973
+ "backend",
973
974
  "args",
974
975
  "env",
975
976
  "timeouts",
@@ -981,8 +982,11 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
981
982
  throw new Error("package/config mismatch");
982
983
  if (!isDeepStrictEqual(config.restart, extras.restart))
983
984
  throw new Error("daemon restart differs from its build declaration");
985
+ const backend = config.backend === true;
986
+ if (config.backend !== undefined && !backend)
987
+ throw new Error("invalid backend flag");
984
988
  const daemon = readDaemon({
985
- exec: config.exec,
989
+ ...(backend ? { backend: true } : { exec: config.exec }),
986
990
  args: config.args,
987
991
  uninstall_args: config.uninstall_args,
988
992
  env: config.env,
@@ -994,6 +998,12 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
994
998
  throw new Error("invalid config");
995
999
  // The packaged config already carries the launcher argv; only the launcher itself is checked here.
996
1000
  if (extras.node &&
1001
+ backend &&
1002
+ (daemon.env.SYNOPKGLAND_NODE !== backendNodeEnv().SYNOPKGLAND_NODE ||
1003
+ readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
1004
+ throw new Error("Node launcher differs from its build declaration");
1005
+ if (extras.node &&
1006
+ !backend &&
997
1007
  (daemon.exec !== NODE_LAUNCHER ||
998
1008
  !daemon.args[0]?.startsWith("${SYNOPKG_PKGDEST}/daemon/payload/") ||
999
1009
  readOptional(join(unpacked, "daemon/payload", NODE_LAUNCHER)) !== renderNodeLauncher(extras.node)))
@@ -1007,7 +1017,8 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
1007
1017
  JSON.stringify(daemon.args) !== '["run"]' ||
1008
1018
  daemon.uninstall_args))
1009
1019
  throw new Error("compose gateway must run without a child");
1010
- if (payload.find((p) => p.path === `daemon/payload/${daemon.exec}`)?.mode !==
1020
+ // A backend daemon runs the audited CGI binary.
1021
+ if (payload.find((p) => p.path === (backend ? CGI_PAYLOAD_PATH : `daemon/payload/${daemon.exec}`))?.mode !==
1011
1022
  "0755")
1012
1023
  throw new Error("missing executable");
1013
1024
  const manifest = {
@@ -312,8 +312,8 @@ function readManifest(raw, c) {
312
312
  if (problem)
313
313
  c.fail("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem);
314
314
  }
315
- if ((daemon?.ingress || daemon?.node) && raw.identity_gateway !== undefined)
316
- c.fail("STANDALONE_DAEMON_INVALID", "daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
315
+ if ((daemon?.ingress || daemon?.node || daemon?.backend) && raw.identity_gateway !== undefined)
316
+ c.fail("STANDALONE_DAEMON_INVALID", "daemon.backend, daemon.node and daemon.ingress run the App's own daemon; identity_gateway runs its gateway as the daemon. Use one or the other.");
317
317
  if (compose && identityGateway && compose.sdk !== identityGateway.sdk)
318
318
  c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose and identity_gateway must use the same SDK release.");
319
319
  if (compose &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "~0.34.0",
30
+ "@synopackageland/templates": "~0.35.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- The CLI installs templates through `~0.34.0`. A templates patch needs only a
8
+ The CLI installs templates through `~0.35.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -24,7 +24,7 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
- Use SDK/App 0.36.0 and CLI 0.38.0 for App entry enforcement. Declare every App method
27
+ Use SDK/App 0.37.0 and CLI 0.40.0 for App entry enforcement. Declare every App method
28
28
  in the permissions table; dynamic names and unregistered HTTP dispatchers are rejected.
29
29
  Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
30
  paths that rely on upstream authentication. Read
@@ -118,6 +118,8 @@ entries are injected, and the declaration grants no private-data access.
118
118
 
119
119
  Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting; DSM 7.2-64570+ supports schema-2 Portal without an executor, while DSM 7.1 requires executor registration. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate native runtimes in @synopackageland/sdk/native; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
120
120
 
121
+ Before choosing `daemon.backend` (the backend as a resident daemon behind `daemon.ingress`) for speed, read [reference/daemon-mode.md](reference/daemon-mode.md): measure the CGI first; the daemon pays off only for repeated, cacheable per-request work on a NAS with memory to spare.
122
+
121
123
  Read [reference/lifecycle.md](reference/lifecycle.md) before editing `dsm-scripts/` (preinst, postinst, preupgrade, postupgrade, preuninst, postuninst, start-stop-status): the SDK runs them inside its generated scripts as the package account; never add root work or repeat SDK steps there.
122
124
 
123
125
  Native Apps appear in Hyper Backup as applications by default. Read [reference/backup.md](reference/backup.md) before changing `backup` in dsm-package.yaml (opt out with `hyper_backup: false`, online mode, data version, hook). Hyper Backup runs the generated `scripts/backup/` as root; it is a fixed template that only switches to the package account, so never add or edit files there.
@@ -0,0 +1,90 @@
1
+ # When to run the backend as a daemon
2
+
3
+ A native App's backend normally runs as a CGI: DSM starts `ui/cgi/api.cgi`
4
+ for every request, and the process exits when the request ends. With
5
+ `daemon.backend: true` and `daemon.ingress` ([docs/daemon.md](../../docs/daemon.md)),
6
+ the same binary runs once as the package daemon and serves the pages through
7
+ DSM's nginx. The CGI stays for integrations, transfers and fallback.
8
+
9
+ Daemon mode is not the default recommendation. It removes work that repeats
10
+ on every request. It does not make a single expensive request cheaper, and it
11
+ costs memory for as long as the package runs. Measure first, then decide.
12
+
13
+ ## Decision table
14
+
15
+ | Factor | Daemon helps | Stay with CGI |
16
+ | --- | --- | --- |
17
+ | Fixed cost per request | High: each request loads or compiles many definitions, opens several stores or runs sign-in checks that could be remembered | Low: the handler reads a row or two and returns |
18
+ | Request rate | Pages send many calls; many users at once | A few calls per page; few users |
19
+ | Repeated, cacheable work | Most of the time goes to work whose result does not change between requests (compiled definitions, permission answers, zone data) | Each request does new work (one-off reports, large exports) |
20
+ | Long connections | Streams, server-sent events, long polling | None |
21
+ | Background work | A tick, outbox sending or polling that should run within seconds | A scheduled task every few minutes is enough |
22
+ | App size | Many Apps, large definitions | One small App |
23
+ | NAS memory | Enough room for the base process plus a bounded cache | Small models (2–4 GB) that already swap; every resident MB competes with DSM and other packages |
24
+ | Idle cost | Acceptable: the process stays up when nobody uses the App | Not acceptable: the App is opened rarely |
25
+
26
+ The base process is usually small: an idle Go daemon is typically 10–30 MB
27
+ RSS. Measure your own binary. The real variable is the cache, so give every
28
+ cache an upper bound and measure it filled.
29
+
30
+ ## Factors and example numbers
31
+
32
+ These numbers come from Studio Office. Use them as an example of what
33
+ to measure, not as a budget.
34
+
35
+ - **CGI cost per request.** On a DS SA3400 with 25 Apps, every Studio call
36
+ spent 100–140 ms before the handler did any work: process start, the DSM
37
+ sign-in check (`authenticate.cgi`), the App Privileges check and schema
38
+ checks. Recompiling App definitions on each request took about 70% of
39
+ `studio.apps.list`. Measure this on the NAS: time an empty call (for
40
+ example `app.status`) and the slowest common page calls.
41
+ - **Resident memory.** Measured on macOS arm64 in a Go test process, not
42
+ on the NAS: 37 MB RSS with the routes built and the store opened, and
43
+ 81 MB after 23 compiled template Apps were kept (about 1.1 MB of
44
+ heap per App).
45
+ The Node AI child has not been measured. Measure on the target NAS with your
46
+ real data before you choose cache sizes.
47
+ - **Cache bounds.** Bound each cache by entry count (LRU) and derive the bound
48
+ from the measured size per entry. Studio keeps 32 compiled releases, about
49
+ 35 MB at the measured size. Cache only results that are pure functions of
50
+ their key, such as compiled definitions keyed by a hash of the stored plan,
51
+ and cache only successes.
52
+ - **Sign-in cache.** `dsm.CacheSessions` remembers verified sessions for a
53
+ TTL you choose (Studio: 10 minutes). The cost is staleness: a sign-out, a
54
+ disabled account or a lost administrator role takes effect up to the TTL
55
+ later. App Privileges keep their own 30-second check.
56
+ - **Crash and restart.** The SDK daemon controller restarts the daemon
57
+ (`daemon.restart`). Children of the daemon, such as a Node service, are the
58
+ backend's own job: start them, restart them with backoff and stop them on
59
+ SIGTERM. Keep the CGI as fallback, so pages still work when the daemon is
60
+ down. Retry only reads automatically, plus calls the daemon cannot have seen.
61
+ - **Upgrade.** DSM stops the old daemon and starts the new one. In-memory
62
+ caches start empty and fill on first use. Migrations still run at start.
63
+ The CGI is the same binary, so both paths always run the same version.
64
+ - **Security boundary.** The daemon serves the same handler as the CGI. Origin,
65
+ App entry, DSM session and route permission are checked on every request.
66
+ The ingress socket is mode 0666 because nginx runs as another account, so
67
+ a local process can connect directly and choose `X-Real-IP`. A valid DSM
68
+ session is still required, but IP-based App Privileges rules can be
69
+ bypassed this way. The route audit treats `api.ServeIngress` like
70
+ `cgi.Serve`.
71
+
72
+ ## Decision steps
73
+
74
+ 1. Measure the CGI. Time an empty authenticated call and the 3–5 calls a
75
+ common page makes, on the target NAS, a few times each. Count how many
76
+ calls one page sends.
77
+ 2. Find where the time goes. Split fixed cost (process start, sign-in, entry
78
+ checks) from handler work. Then split handler work into repeated,
79
+ cacheable work and work that changes per request.
80
+ 3. Optimize inside the CGI first, if that is enough:
81
+ - Send fewer requests: batch reads, and return all items in one page instead
82
+ of serial paging.
83
+ - Use disk caches keyed by content hash (for example compiled definitions
84
+ next to the store).
85
+ - Avoid loading what the call does not use.
86
+ 4. Choose the daemon only when, after step 3, most of the remaining time is
87
+ fixed cost or cacheable repeated work, and the NAS has memory for the
88
+ measured base plus the bounded cache.
89
+ 5. After switching, measure idle RSS and RSS with a full cache on the NAS, and
90
+ the same page calls as in step 1. Write the numbers into the App's docs.