@dsh-plugin/dsh-loader 1.0.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/src/index.js ADDED
@@ -0,0 +1,105 @@
1
+ // dshloader host bundle entry (design.md §7.3 / §4.4).
2
+ //
3
+ // Exports the cordis function-plugin shape (`name`, `inject`, `apply`) and
4
+ // wires the adapter registry → version detection → stable API onto
5
+ // `ctx.dshLoader`. Service aliases / bridge routes register through
6
+ // ctx.reflect.provide / ctx.effect so cordis auto-recycles them on fiber
7
+ // unload — no manual dispose is required for v1's covered capabilities.
8
+ import { LOADER_VERSION, LOG_PREFIX } from './version.js';
9
+ import { readFileSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { AdapterRegistry, detectDshVersion, UnsupportedDshVersionError } from './registry.js';
12
+ import { registerHostAdapters } from './adapters/index.js';
13
+ import { createHostAPI } from './api.js';
14
+
15
+ export const name = '@dsh-plugin/dsh-loader';
16
+ // dshloader itself depends on `webServer` so its fiber only activates once
17
+ // the real web server is provided — the alias then points `httpServer` at it.
18
+ export const inject = ['webServer'];
19
+
20
+ /**
21
+ * Read the profile-level dshloader config (exposeAllNamespaces etc.).
22
+ * Sources, in priority order:
23
+ * 1. process.env.DSHLOADER_EXPOSE_ALL_SETTINGS=1
24
+ * 2. profile package.json `dsh.dshloader.exposeAllNamespaces`
25
+ * 3. profile package.json `dshLoader.settings.exposeAllNamespaces`
26
+ */
27
+ export function readLoaderConfig({ profileDir } = {}) {
28
+ const envOn = process.env.DSHLOADER_EXPOSE_ALL_SETTINGS === '1' || process.env.DSHLOADER_EXPOSE_ALL_SETTINGS === 'true';
29
+ let pkgOn = false;
30
+ try {
31
+ // Best-effort: read the profile manifest if reachable via cwd.
32
+ const dir = profileDir ?? join(process.env.DSH_HOME ?? '', 'profiles', 'web');
33
+ const manifest = JSON.parse(readFileSync(join(resolve(dir), 'package.json'), 'utf8'));
34
+ pkgOn = Boolean(
35
+ manifest.dsh?.dshloader?.exposeAllNamespaces ??
36
+ manifest.dshLoader?.settings?.exposeAllNamespaces,
37
+ );
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ return { exposeAllNamespaces: envOn || pkgOn };
42
+ }
43
+
44
+ /**
45
+ * Build the registry + select an adapter for the current dsh version, without
46
+ * touching the cordis context. Exported for tests and the `info` CLI command.
47
+ */
48
+ export function selectAdapter({ dshVersion, registry } = {}) {
49
+ const reg = registry ?? registerHostAdapters(new AdapterRegistry());
50
+ const version = dshVersion ?? detectDshVersion();
51
+ if (version === undefined) {
52
+ throw new UnsupportedDshVersionError(
53
+ `${LOG_PREFIX} could not detect dsh version; set DSHLOADER_DSH_VERSION or install @deepseek-ai/dsh`,
54
+ { kind: 'too-new' },
55
+ );
56
+ }
57
+ const { factory, mode } = reg.select(version);
58
+ return { registry: reg, factory, mode, dshVersion: version };
59
+ }
60
+
61
+ /**
62
+ * Apply the selected adapter onto a cordis context and expose the stable API.
63
+ * @param {object} ctx cordis context
64
+ * @param {{ dshVersion?: string, config?: object, registry?: AdapterRegistry }} [opts]
65
+ * @returns {Promise<{ api: object, factory: object, mode: string, dshVersion: string }>}
66
+ */
67
+ export async function applyAdapter(ctx, opts = {}) {
68
+ const config = opts.config ?? readLoaderConfig();
69
+ const selection = selectAdapter({ dshVersion: opts.dshVersion, registry: opts.registry });
70
+ const { factory, mode, dshVersion } = selection;
71
+
72
+ const adapter = factory.create(ctx, config);
73
+ await adapter.apply?.();
74
+
75
+ const api = createHostAPI({
76
+ ctx,
77
+ dshVersion,
78
+ factory,
79
+ adapter,
80
+ exposeAllNamespaces: config.exposeAllNamespaces,
81
+ hostPackageAliases: config.hostPackageAliases,
82
+ });
83
+ ctx.reflect.provide('dshLoader', api);
84
+
85
+ console.log(`${LOG_PREFIX} loaded adapter ${factory.name} for dsh ${dshVersion} (mode: ${mode})`);
86
+ console.log(`${LOG_PREFIX} registered stable API: settings, web, services`);
87
+ if (config.exposeAllNamespaces) {
88
+ console.warn(
89
+ `${LOG_PREFIX} exposeAllNamespaces enabled: bypassing official settings whitelist`,
90
+ );
91
+ }
92
+
93
+ return { api, factory, mode, dshVersion };
94
+ }
95
+
96
+ /** cordis function-plugin entry point. */
97
+ export async function apply(ctx) {
98
+ if (process.env.DSHLOADER_DISABLE === '1' || process.env.DSHLOADER_DISABLE === 'true') {
99
+ console.log(`${LOG_PREFIX} disabled by env, skipping`);
100
+ return;
101
+ }
102
+ await applyAdapter(ctx);
103
+ }
104
+
105
+ export { LOADER_VERSION };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Adapter registry + dsh version detection (design.md §3.1 / §3.2).
3
+ *
4
+ * Selection rules (mirrors design.md §3.1, five rules):
5
+ * 1. exact match — adapter.supports === version
6
+ * 2. range match — semver.satisfies(version, supports); when several
7
+ * ranges cover the version, pick the narrowest; ties
8
+ * broken by last-registered-wins.
9
+ * 3. nearest-low fallback — no exact/range hit, but some adapters only cover
10
+ * versions below the real one: pick the one whose
11
+ * upper bound is closest, mark mode 'fallback', warn.
12
+ * 4. version too old — real version is below every adapter's lower bound:
13
+ * throw UnsupportedDshVersionError with a "too old"
14
+ * message and the lowest supported version.
15
+ * 5. version too new / empty registry — throw UnsupportedDshVersionError with
16
+ * an "upgrade dshloader" message.
17
+ *
18
+ * Version detection priority (design.md §3.2):
19
+ * 1. DSHLOADER_DSH_VERSION env var (highest; for tests/CI override)
20
+ * 2. node_modules/@deepseek-ai/dsh/package.json#version
21
+ * 3. ctx.runtime?.version — reserved for the future, NOT consulted in v1.
22
+ * child_process `dsh --version` is deliberately NOT used (design.md §3.2).
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ import { join, resolve, dirname } from 'node:path';
26
+ import { createRequire } from 'node:module';
27
+ import semver from 'semver';
28
+ import { LOG_PREFIX } from './version.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ export class UnsupportedDshVersionError extends Error {
33
+ constructor(message, { kind, version, minSupported } = {}) {
34
+ super(message);
35
+ this.name = 'UnsupportedDshVersionError';
36
+ this.kind = kind; // 'too-old' | 'too-new'
37
+ this.version = version;
38
+ this.minSupported = minSupported;
39
+ }
40
+ }
41
+
42
+ export class InvalidVersionError extends Error {
43
+ constructor(message, { version } = {}) {
44
+ super(message);
45
+ this.name = 'InvalidVersionError';
46
+ this.version = version;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolve the installed dsh version.
52
+ *
53
+ * @param {{ profileDir?: string, dshPkgPath?: string, env?: NodeJS.ProcessEnv }} [opts]
54
+ * @returns {string|undefined} semver version string, or undefined when unreachable
55
+ */
56
+ export function detectDshVersion(opts = {}) {
57
+ const env = opts.env ?? process.env;
58
+ const envVal = env.DSHLOADER_DSH_VERSION;
59
+ if (typeof envVal === 'string' && envVal.trim() !== '') {
60
+ return envVal.trim();
61
+ }
62
+ const candidates = [];
63
+ if (opts.dshPkgPath) candidates.push(opts.dshPkgPath);
64
+ if (opts.profileDir) {
65
+ candidates.push(join(opts.profileDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
66
+ }
67
+ // Resolve from the loader's own location (profile node_modules sits beside it).
68
+ try {
69
+ candidates.push(require.resolve('@deepseek-ai/dsh/package.json'));
70
+ } catch { /* not installed — skip */ }
71
+ // Global node_modules — dsh is typically installed globally (the runtime
72
+ // that loads profiles, not a profile dependency). Derive from the Node.js
73
+ // executable: /opt/homebrew/bin/node → /opt/homebrew/lib/node_modules.
74
+ const globalRoot = resolve(dirname(process.execPath), '..', 'lib', 'node_modules');
75
+ candidates.push(join(globalRoot, '@deepseek-ai', 'dsh', 'package.json'));
76
+ // Walk up from cwd as a last resort.
77
+ let dir = process.cwd();
78
+ for (let i = 0; i < 8; i += 1) {
79
+ candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'));
80
+ const parent = resolve(dir, '..');
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+ for (const path of candidates) {
85
+ try {
86
+ const pkg = JSON.parse(readFileSync(path, 'utf8'));
87
+ if (typeof pkg.version === 'string' && pkg.version.trim() !== '') {
88
+ return pkg.version.trim();
89
+ }
90
+ } catch { /* try next */ }
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ /**
96
+ * Parse a semver range into { lower, upper } bounds. Each bound is
97
+ * { v: SemVer, inc: boolean } or null when unbounded on that side.
98
+ */
99
+ function rangeBounds(range) {
100
+ const r = new semver.Range(range);
101
+ let lower = null;
102
+ let upper = null;
103
+ for (const group of r.set) {
104
+ for (const c of group) {
105
+ const op = c.operator;
106
+ const v = c.semver; // parsed SemVer (c.value is the raw comparator string)
107
+ if (op === '' || op === '=') {
108
+ lower = { v, inc: true };
109
+ upper = { v, inc: true };
110
+ } else if (op === '>') {
111
+ lower = { v, inc: false };
112
+ } else if (op === '>=') {
113
+ lower = { v, inc: true };
114
+ } else if (op === '<') {
115
+ upper = { v, inc: false };
116
+ } else if (op === '<=') {
117
+ upper = { v, inc: true };
118
+ }
119
+ }
120
+ }
121
+ return { lower, upper };
122
+ }
123
+
124
+ /** True when every version the range covers is strictly below `version`. */
125
+ function isLowerCandidate(range, version) {
126
+ const { upper } = rangeBounds(range);
127
+ if (!upper) return false; // unbounded above → can cover version, not a lower candidate
128
+ return upper.inc ? semver.gt(version, upper.v) : semver.gte(version, upper.v);
129
+ }
130
+
131
+ /** Lowest version that satisfies the range (semver.minVersion). */
132
+ function rangeMinVersion(range) {
133
+ const min = semver.minVersion(range);
134
+ return min ? min.version : null;
135
+ }
136
+
137
+ export class AdapterRegistry {
138
+ constructor() {
139
+ /** @type {Array<{supports: string, name: string, create: Function}>} */
140
+ this.adapters = [];
141
+ }
142
+
143
+ register(factory) {
144
+ if (!factory || typeof factory.supports !== 'string' || typeof factory.create !== 'function') {
145
+ throw new TypeError('AdapterFactory must expose { supports: string, create: function }');
146
+ }
147
+ this.adapters.push(factory);
148
+ return this;
149
+ }
150
+
151
+ /**
152
+ * @param {string} version real dsh version
153
+ * @returns {{ factory: object, mode: 'exact'|'range'|'fallback' }}
154
+ */
155
+ select(version) {
156
+ if (!semver.valid(version)) {
157
+ throw new InvalidVersionError(
158
+ `${LOG_PREFIX} cannot parse dsh version "${version}" as semver`,
159
+ { version },
160
+ );
161
+ }
162
+ if (this.adapters.length === 0) {
163
+ throw new UnsupportedDshVersionError(
164
+ `${LOG_PREFIX} no adapter registered for dsh ${version}; please upgrade @dsh-plugin/dsh-loader`,
165
+ { kind: 'too-new', version },
166
+ );
167
+ }
168
+
169
+ // Rule 1 + 2: exact and range matches.
170
+ const matches = [];
171
+ for (const factory of this.adapters) {
172
+ if (factory.supports === version) {
173
+ matches.push({ factory, mode: 'exact' });
174
+ } else if (semver.satisfies(version, factory.supports)) {
175
+ matches.push({ factory, mode: 'range' });
176
+ }
177
+ }
178
+ if (matches.length > 0) {
179
+ // Narrowest range wins; tie → last registered.
180
+ let best = matches[matches.length - 1];
181
+ for (let i = matches.length - 1; i >= 0; i -= 1) {
182
+ const candidate = matches[i];
183
+ const narrowest = matches.every((other, j) => {
184
+ if (i === j) return true;
185
+ try {
186
+ return semver.subset(candidate.factory.supports, other.factory.supports);
187
+ } catch {
188
+ return false;
189
+ }
190
+ });
191
+ if (narrowest) {
192
+ best = candidate;
193
+ break;
194
+ }
195
+ }
196
+ // Exact match always beats a range match on the same version.
197
+ const exact = matches.find((m) => m.mode === 'exact');
198
+ if (exact) best = exact;
199
+ return { factory: best.factory, mode: best.mode };
200
+ }
201
+
202
+ // Rule 3: nearest-low fallback.
203
+ const lowers = this.adapters
204
+ .filter((f) => isLowerCandidate(f.supports, version))
205
+ .map((f) => {
206
+ const { upper } = rangeBounds(f.supports);
207
+ return { factory: f, upper };
208
+ });
209
+ if (lowers.length > 0) {
210
+ lowers.sort((a, b) => {
211
+ const cmp = semver.compare(b.upper.v, a.upper.v);
212
+ if (cmp !== 0) return cmp;
213
+ // exclusive upper is "higher" than inclusive at the same version
214
+ return (b.upper.inc ? 0 : 1) - (a.upper.inc ? 0 : 1);
215
+ });
216
+ const chosen = lowers[0];
217
+ console.warn(
218
+ `${LOG_PREFIX} no exact adapter for dsh ${version}; falling back to "${chosen.factory.name}" (supports ${chosen.factory.supports})`,
219
+ );
220
+ return { factory: chosen.factory, mode: 'fallback' };
221
+ }
222
+
223
+ // Rule 4 vs 5: too old vs too new.
224
+ const minVersions = this.adapters
225
+ .map((f) => rangeMinVersion(f.supports))
226
+ .filter(Boolean)
227
+ .map((v) => semver.parse(v));
228
+ if (minVersions.length > 0) {
229
+ const lowest = minVersions.reduce((acc, v) => (semver.lt(v, acc) ? v : acc));
230
+ if (semver.lt(version, lowest)) {
231
+ throw new UnsupportedDshVersionError(
232
+ `${LOG_PREFIX} current dsh version ${version} is too old; dshloader minimum supported is ${lowest.version}. Please upgrade dsh or use an older dshloader release.`,
233
+ { kind: 'too-old', version, minSupported: lowest.version },
234
+ );
235
+ }
236
+ }
237
+ throw new UnsupportedDshVersionError(
238
+ `${LOG_PREFIX} no adapter covers dsh ${version}; please upgrade @dsh-plugin/dsh-loader`,
239
+ { kind: 'too-new', version },
240
+ );
241
+ }
242
+ }
@@ -0,0 +1,28 @@
1
+ // Services stable API (design.md §3.3 / §4.1 `ctx.dshLoader.services`).
2
+ //
3
+ // Thin read/alias helpers over the cordis service registry. `alias()` is a
4
+ // low-level escape hatch for plugin authors who need a one-hop alias beyond
5
+ // what the selected adapter already provides; it never overwrites an existing
6
+ // service (mirrors the adapter safety rule in design.md §5.1).
7
+
8
+ import { LOG_PREFIX } from '../version.js';
9
+
10
+ export function createServicesAPI({ ctx }) {
11
+ return {
12
+ get(name) {
13
+ return ctx.get(name);
14
+ },
15
+ alias(from, to) {
16
+ if (ctx.get(from) !== undefined) {
17
+ console.warn(`${LOG_PREFIX} services.alias: "${from}" already exists, skip alias`);
18
+ return;
19
+ }
20
+ const target = ctx.get(to);
21
+ if (target === undefined) {
22
+ console.warn(`${LOG_PREFIX} services.alias: target "${to}" unavailable, cannot alias "${from}"`);
23
+ return;
24
+ }
25
+ ctx.reflect.provide(from, target);
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,174 @@
1
+ // Settings stable API (design.md §3.3.1 / §4.1 / §4.2).
2
+ //
3
+ // Two concerns are deliberately separated:
4
+ // 1. naming/shape differences — handled by proxying to ctx.get('settings').
5
+ // 2. access scope (security) — `exposeAllNamespaces` controls whether
6
+ // `describe` returns only the official browser whitelist namespaces
7
+ // (default, matches official behavior) or every registered namespace.
8
+ //
9
+ // Host-side writes always proxy through to the real settings service: host
10
+ // plugin code is already trusted (it runs in the dsh Node process and could
11
+ // call ctx.get('settings').update directly). The whitelist is a browser-side
12
+ // default-deny boundary owned by dsh-host-apiproxy; the browser path is
13
+ // covered separately by the client fetch interceptor (src/client.js).
14
+ import { LOG_PREFIX } from '../version.js';
15
+
16
+ /**
17
+ * Official browser-writable settings namespaces (dsh-host-apiproxy
18
+ * WEB_SETTINGS_NAMESPACES), as documented in dsh-upstream-fixes/README.md.
19
+ * Product namespaces and dynamic model-provider namespaces are not enumerable
20
+ * without dsh internals; callers may extend this set via `extraWhitelist`.
21
+ */
22
+ export const DEFAULT_WEB_SETTINGS_NAMESPACES = new Set([
23
+ 'agent-loop',
24
+ 'shell',
25
+ 'locale',
26
+ 'permission',
27
+ 'ui-conversation',
28
+ 'ui-theme',
29
+ 'web-search-deepseek',
30
+ ]);
31
+
32
+ /**
33
+ * Shape a raw settings descriptor into the official NamespaceView wire shape
34
+ * (mirrors dsh-upstream-fixes/lib/index.js `namespaceView`).
35
+ */
36
+ export function toNamespaceView(descriptor) {
37
+ const view = {
38
+ ns: String(descriptor.ns),
39
+ schema: descriptor.schema,
40
+ value: descriptor.value,
41
+ ...(descriptor.base === undefined ? {} : { base: descriptor.base }),
42
+ ...(descriptor.user === undefined ? {} : { user: descriptor.user }),
43
+ applies: descriptor.applies,
44
+ secrets: (descriptor.secrets ?? []).map((s) => ({ path: [...s.path], set: s.set })),
45
+ revision: descriptor.revision,
46
+ };
47
+ return view;
48
+ }
49
+
50
+ /**
51
+ * Map a thrown settings error into a SettingsResult, preserving the official
52
+ * `settings-conflict` / `settings-rejected` classification.
53
+ */
54
+ export function settingsErrorToResult(error, ns, method) {
55
+ const isObject = error !== null && typeof error === 'object';
56
+ const isConflict = isObject && ('expected' in error || 'actual' in error);
57
+ if (isConflict) {
58
+ return {
59
+ ok: false,
60
+ code: 'settings-conflict',
61
+ message: error.message ?? String(error),
62
+ details: {
63
+ ns,
64
+ ...(error.expected === undefined ? {} : { expected: error.expected }),
65
+ ...(error.actual === undefined ? {} : { actual: error.actual }),
66
+ },
67
+ };
68
+ }
69
+ return {
70
+ ok: false,
71
+ code: 'settings-rejected',
72
+ message: error instanceof Error ? error.message : String(error),
73
+ details: { ns, method },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Build the `ctx.dshLoader.settings` stable API.
79
+ *
80
+ * @param {{ ctx: object, exposeAllNamespaces: boolean, whitelist?: Set<string> }} opts
81
+ */
82
+ export function createSettingsAPI({ ctx, exposeAllNamespaces, whitelist }) {
83
+ const allowed = whitelist ?? DEFAULT_WEB_SETTINGS_NAMESPACES;
84
+
85
+ function getSettings() {
86
+ return ctx.get('settings');
87
+ }
88
+
89
+ function filterNamespaces(views) {
90
+ if (exposeAllNamespaces) return views;
91
+ return views.filter((v) => allowed.has(String(v.ns)));
92
+ }
93
+
94
+ const api = {
95
+ exposeAllNamespaces: Boolean(exposeAllNamespaces),
96
+
97
+ /**
98
+ * Register a settings namespace. Proxies to the real settings service's
99
+ * `register(ns, schema, options)` and returns the owner scope
100
+ * ({ get, watch }) — the same shape the official service returns.
101
+ *
102
+ * Unlike describe/update/replace/mutate, register is NOT filtered by the
103
+ * whitelist: host plugin code is trusted and registering a namespace is
104
+ * a composition-time act, not a browser-facing read/write.
105
+ *
106
+ * @param {string} ns - unique namespace (lowercase kebab-case)
107
+ * @param {object} schema - schemastery schema for this namespace
108
+ * @param {{ base?: object, applies?: 'live'|'restart', validate?: (value:any)=>void }} [options]
109
+ * @returns {{ get: () => any, watch: (cb: (value:any)=>void) => () => void } | undefined}
110
+ */
111
+ register(ns, schema, options) {
112
+ const settings = getSettings();
113
+ if (settings === undefined || typeof settings.register !== 'function') {
114
+ console.warn(`${LOG_PREFIX}:settings.register settings service unavailable`);
115
+ return undefined;
116
+ }
117
+ return settings.register(ns, schema, options);
118
+ },
119
+
120
+ describe(options = {}) {
121
+ const settings = getSettings();
122
+ if (settings === undefined || typeof settings.describe !== 'function') {
123
+ return [];
124
+ }
125
+ const redactSecrets = options.redactSecrets !== false;
126
+ const descriptors = settings.describe({ redactSecrets });
127
+ const views = (descriptors ?? []).map(toNamespaceView);
128
+ return filterNamespaces(views);
129
+ },
130
+
131
+ async _write(method, ns, section, expectedRevision) {
132
+ const settings = getSettings();
133
+ if (settings === undefined) {
134
+ return {
135
+ ok: false,
136
+ code: 'internal',
137
+ message: `${LOG_PREFIX}:settings.${method} settings service unavailable`,
138
+ details: { ns },
139
+ };
140
+ }
141
+ try {
142
+ if (method === 'update') await settings.update(ns, section, expectedRevision);
143
+ else if (method === 'replace') await settings.replace(ns, section, expectedRevision);
144
+ else await settings.mutate(ns, section, expectedRevision);
145
+ } catch (error) {
146
+ return settingsErrorToResult(error, ns, method);
147
+ }
148
+ const descriptor = settings
149
+ .describe({ redactSecrets: true })
150
+ .find((d) => String(d.ns) === String(ns));
151
+ if (descriptor === undefined) {
152
+ return {
153
+ ok: false,
154
+ code: 'internal',
155
+ message: `${LOG_PREFIX}:settings.${method} namespace disposed after write`,
156
+ details: { ns },
157
+ };
158
+ }
159
+ return { ok: true, value: toNamespaceView(descriptor) };
160
+ },
161
+
162
+ update(ns, section, expectedRevision) {
163
+ return api._write('update', ns, section, expectedRevision);
164
+ },
165
+ replace(ns, section, expectedRevision) {
166
+ return api._write('replace', ns, section, expectedRevision);
167
+ },
168
+ mutate(ns, ops, expectedRevision) {
169
+ return api._write('mutate', ns, ops, expectedRevision);
170
+ },
171
+ };
172
+
173
+ return api;
174
+ }
@@ -0,0 +1,71 @@
1
+ // Web server stable API (design.md §3.3.2 / §4.1).
2
+ //
3
+ // Routes every registration through the real `webServer` (or `httpServer`
4
+ // alias) `register({ kind, ... })` call shape used by dsh 1.x, mirroring
5
+ // dsh-upstream-fixes/lib/index.js `registerRoutes`. Each method returns a
6
+ // dispose function that removes the registration when the underlying
7
+ // service supports it.
8
+ import { LOG_PREFIX } from '../version.js';
9
+
10
+ export class DshLoaderWebError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'DshLoaderWebError';
14
+ }
15
+ }
16
+
17
+ /** Resolve the active web server service (webServer preferred, httpServer alias next). */
18
+ function resolveWebServer(ctx) {
19
+ return ctx.get('webServer') ?? ctx.get('httpServer');
20
+ }
21
+
22
+ /**
23
+ * Build the `ctx.dshLoader.web` stable API.
24
+ * @param {{ ctx: object }} opts
25
+ */
26
+ export function createWebAPI({ ctx }) {
27
+ function server() {
28
+ const web = resolveWebServer(ctx);
29
+ if (web === undefined || typeof web.register !== 'function') {
30
+ throw new DshLoaderWebError(
31
+ `${LOG_PREFIX}:web webServer service unavailable`,
32
+ );
33
+ }
34
+ return web;
35
+ }
36
+
37
+ return {
38
+ register(prefix, handler) {
39
+ const web = server();
40
+ return web.register({ kind: 'prefix', path: prefix, handler }) ?? (() => {});
41
+ },
42
+ get(path, handler) {
43
+ const web = server();
44
+ return web.register({ kind: 'route', method: 'GET', path, handler }) ?? (() => {});
45
+ },
46
+ post(path, handler) {
47
+ const web = server();
48
+ return web.register({ kind: 'route', method: 'POST', path, handler }) ?? (() => {});
49
+ },
50
+ use(middleware) {
51
+ const web = server();
52
+ return web.register({ kind: 'middleware', handler: middleware }) ?? (() => {});
53
+ },
54
+ /**
55
+ * Register a WebSocket upgrade route for an exact pathname.
56
+ * Proxies to `webServer.registerUpgrade({ path, handler })`.
57
+ *
58
+ * @param {{ path: string, handler: (req: any, socket: any, head: Buffer) => void }} route
59
+ * @returns {() => void} dispose function that removes the upgrade route
60
+ */
61
+ registerUpgrade(route) {
62
+ const web = server();
63
+ if (typeof web.registerUpgrade !== 'function') {
64
+ throw new DshLoaderWebError(
65
+ `${LOG_PREFIX}:web.registerUpgrade webServer does not support upgrade routes (registerUpgrade missing)`,
66
+ );
67
+ }
68
+ return web.registerUpgrade(route) ?? (() => {});
69
+ },
70
+ };
71
+ }