@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.
@@ -0,0 +1,236 @@
1
+ // dsh 1.x host adapter (design.md §7.4 / §5.1 / §3.3.1).
2
+ //
3
+ // Absorbs the verified repairs from dsh-upstream-fixes:
4
+ // - httpServer -> webServer single-hop service alias (fix 2).
5
+ // - settings namespace whitelist bypass bridge, gated behind
6
+ // `exposeAllNamespaces` (fix 5). Two independent paths:
7
+ // * host side: ctx.dshLoader.settings.* (handled by services/settings.js
8
+ // + the bridge routes registered here for the browser fetch path).
9
+ // * client side: fetch interceptor in src/client.js.
10
+ // - package-name aliasing for host-side CJS require (createRequire /
11
+ // require()) — intercepts Module._resolveFilename so a renamed dsh
12
+ // package resolves to its new name without plugin rebuilds.
13
+ //
14
+ // All registrations go through ctx.reflect.provide / ctx.effect so cordis
15
+ // auto-recycles them when the dshloader fiber unloads (design.md §4.4). No
16
+ // custom dispose() is needed for v1's covered capabilities.
17
+ import { LOG_PREFIX } from '../version.js';
18
+ import { toNamespaceView, settingsErrorToResult } from '../services/settings.js';
19
+
20
+ // Covers the real dsh release line (0.1.0-rc.x, verified against
21
+ // dsh-upstream-fixes) and the anticipated 1.x line. The adapter name keeps
22
+ // the design-doc label "dsh-1-x"; the behavior is identical across both
23
+ // ranges since dsh-upstream-fixes confirms httpServer->webServer + settings
24
+ // bridge work the same way on 0.1.0-rc.7.
25
+ export const supports = '>=0.1.0-rc.1 <2.0.0';
26
+ export const name = 'dsh-1-x';
27
+
28
+ // Browser-facing bridge prefix; the client fetch interceptor (src/client.js)
29
+ // must use the same prefix.
30
+ export const BRIDGE_PREFIX = '/api/dshloader';
31
+
32
+ // Host-side package-name aliases. Maps stable @dshloader/* names to the
33
+ // real dsh package names for this version. Plugins should require() from
34
+ // the stable names; the Module._resolveFilename hook maps them to the real
35
+ // package. When dsh renames a host package, only this table changes.
36
+ //
37
+ // This intercepts CJS require() (including createRequire) via
38
+ // Module._resolveFilename; ESM static imports are build-time resolved and
39
+ // cannot be intercepted at runtime (use pnpm overrides / tsconfig paths
40
+ // for those).
41
+ export const hostPackageAliases = {
42
+ '@dsh-plugin/dsh-loader/tools': '@deepseek-ai/dsh-tools',
43
+ '@dsh-plugin/dsh-loader/llm': '@deepseek-ai/dsh-llm',
44
+ '@dsh-plugin/dsh-loader/agent': '@deepseek-ai/dsh-agent',
45
+ '@dsh-plugin/dsh-loader/settings': '@deepseek-ai/dsh-settings',
46
+ };
47
+
48
+ /**
49
+ * Install a Module._resolveFilename hook that maps old package names to
50
+ * new ones for CJS require() calls. Returns a dispose function that
51
+ * removes the hook.
52
+ */
53
+ export async function installHostPackageAliases(aliases) {
54
+ const entries = Object.entries(aliases);
55
+ if (entries.length === 0) return () => {};
56
+ const aliasMap = new Map(entries);
57
+ // Lazy-import Module to avoid loading it in browser/test contexts.
58
+ let Module;
59
+ try {
60
+ const mod = await import('node:module');
61
+ Module = mod.default ?? mod.Module ?? mod;
62
+ } catch {
63
+ return () => {};
64
+ }
65
+ if (!Module || typeof Module._resolveFilename !== 'function') return () => {};
66
+ const original = Module._resolveFilename;
67
+ const hooked = function dshloaderResolve(request, parent, ...rest) {
68
+ const mapped = aliasMap.get(request);
69
+ if (mapped !== undefined) return original.call(this, mapped, parent, ...rest);
70
+ return original.call(this, request, parent, ...rest);
71
+ };
72
+ Module._resolveFilename = hooked;
73
+ return () => {
74
+ if (Module._resolveFilename === hooked) Module._resolveFilename = original;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @param {object} ctx cordis context
80
+ * @param {{ exposeAllNamespaces?: boolean, hostPackageAliases?: Record<string,string> }} [config]
81
+ */
82
+ export function create(ctx, config = {}) {
83
+ const exposeAllNamespaces = Boolean(config.exposeAllNamespaces);
84
+ const packageAliases = { ...hostPackageAliases, ...(config.hostPackageAliases ?? {}) };
85
+
86
+ async function apply() {
87
+ // --- service alias: httpServer -> webServer (fix 2) ---
88
+ if (ctx.get('httpServer') === undefined) {
89
+ const webServer = ctx.get('webServer');
90
+ if (webServer !== undefined) {
91
+ ctx.reflect.provide('httpServer', webServer);
92
+ console.log(`${LOG_PREFIX} aliased httpServer -> webServer`);
93
+ }
94
+ } else {
95
+ console.log(`${LOG_PREFIX} httpServer already exists, skip alias`);
96
+ }
97
+
98
+ // --- host package-name aliases (CJS require interception) ---
99
+ if (Object.keys(packageAliases).length > 0) {
100
+ ctx.effect(
101
+ () => installHostPackageAliases(packageAliases),
102
+ 'dshloader: host package-name aliases',
103
+ );
104
+ console.log(`${LOG_PREFIX} installed host package aliases: ${Object.keys(packageAliases).join(', ')}`);
105
+ }
106
+
107
+ // --- settings bridge routes for the browser fetch path (fix 5, path 2) ---
108
+ // Only registered when the profile explicitly opts in. The host-side
109
+ // stable API (ctx.dshLoader.settings.*) is unaffected by this gate and
110
+ // always available to host plugin code.
111
+ if (exposeAllNamespaces) {
112
+ ctx.effect(
113
+ () => registerSettingsBridgeRoutes(ctx),
114
+ 'dshloader: settings namespace bridge routes',
115
+ );
116
+ }
117
+ }
118
+
119
+ function dispose() {
120
+ // No cordis-unaware resources held in v1; effect auto-recycle covers
121
+ // everything registered in apply(). Implemented as a no-op to satisfy
122
+ // the HostAdapter interface and future hot-swap scenarios.
123
+ }
124
+
125
+ return { supports, name, apply, dispose };
126
+ }
127
+
128
+ /**
129
+ * Register host bridge routes that the client fetch interceptor forwards
130
+ * non-whitelisted settings requests to. Mirrors dsh-upstream-fixes/lib/index.js
131
+ * `registerRoutes` (settings section).
132
+ */
133
+ function registerSettingsBridgeRoutes(ctx) {
134
+ const webServer = ctx.get('webServer') ?? ctx.get('httpServer');
135
+ if (webServer === undefined || typeof webServer.register !== 'function') return () => {};
136
+ return webServer.register({
137
+ kind: 'prefix',
138
+ path: BRIDGE_PREFIX,
139
+ handler: (req, res) => {
140
+ const json = (status, body) => {
141
+ res.statusCode = status;
142
+ res.setHeader('content-type', 'application/json');
143
+ res.end(JSON.stringify(body));
144
+ };
145
+ const readBody = () =>
146
+ new Promise((done, fail) => {
147
+ let body = '';
148
+ req.on?.('data', (chunk) => {
149
+ body += chunk.toString('utf8');
150
+ });
151
+ req.on?.('end', () => done(body));
152
+ req.on?.('error', fail);
153
+ });
154
+ const url = req.url ?? '/';
155
+ const method = (req.method ?? 'GET').toUpperCase();
156
+ const path = url.split('?')[0] ?? '/';
157
+
158
+ void (async () => {
159
+ try {
160
+ // GET /api/dshloader/settings/describe — full redacted namespace list.
161
+ if (method === 'GET' && (path === `${BRIDGE_PREFIX}/settings/describe` || path === `${BRIDGE_PREFIX}/settings/describe/`)) {
162
+ const settings = ctx.get('settings');
163
+ if (settings === undefined) {
164
+ json(200, { ok: false, message: 'settings service unavailable' });
165
+ return;
166
+ }
167
+ json(200, {
168
+ ok: true,
169
+ namespaces: settings.describe({ redactSecrets: true }).map(toNamespaceView),
170
+ });
171
+ return;
172
+ }
173
+
174
+ // POST /api/dshloader/settings/{update,mutate,replace} — write seam.
175
+ const writeMatch = /^\/api\/dshloader\/settings\/(update|mutate|replace)$/.exec(path);
176
+ if (method === 'POST' && writeMatch !== null) {
177
+ const mode = writeMatch[1];
178
+ let parsed = {};
179
+ try {
180
+ parsed = JSON.parse(await readBody());
181
+ } catch {
182
+ /* keep {} */
183
+ }
184
+ const ns = typeof parsed.ns === 'string' ? parsed.ns : '';
185
+ if (ns.length === 0) {
186
+ json(200, {
187
+ ok: true,
188
+ result: {
189
+ ok: false,
190
+ code: 'settings-rejected',
191
+ message: 'settings write needs a namespace',
192
+ details: {},
193
+ },
194
+ });
195
+ return;
196
+ }
197
+ const settings = ctx.get('settings');
198
+ const result = await runSettingsWrite(
199
+ settings,
200
+ mode,
201
+ ns,
202
+ mode === 'mutate' ? parsed.ops : parsed.section,
203
+ parsed.expectedRevision,
204
+ );
205
+ json(200, { ok: true, result });
206
+ return;
207
+ }
208
+
209
+ json(404, { ok: false, message: 'not found' });
210
+ } catch (error) {
211
+ json(500, { ok: false, message: error instanceof Error ? error.message : String(error) });
212
+ }
213
+ })();
214
+ },
215
+ });
216
+ }
217
+
218
+ async function runSettingsWrite(settings, mode, ns, section, expectedRevision) {
219
+ if (settings === undefined) {
220
+ return { ok: false, code: 'internal', message: 'settings service unavailable', details: {} };
221
+ }
222
+ try {
223
+ if (mode === 'update') await settings.update(ns, section, expectedRevision);
224
+ else if (mode === 'replace') await settings.replace(ns, section, expectedRevision);
225
+ else await settings.mutate(ns, section, expectedRevision);
226
+ } catch (error) {
227
+ return settingsErrorToResult(error, ns, mode);
228
+ }
229
+ const descriptor = settings
230
+ .describe({ redactSecrets: true })
231
+ .find((d) => String(d.ns) === String(ns));
232
+ if (descriptor === undefined) {
233
+ return { ok: false, code: 'internal', message: 'settings namespace disposed after write', details: { ns } };
234
+ }
235
+ return { ok: true, value: toNamespaceView(descriptor) };
236
+ }
@@ -0,0 +1,59 @@
1
+ // Adapter registration (design.md §7.4). Imports every host adapter and
2
+ // registers it on the provided AdapterRegistry. Kept as a function so tests
3
+ // can build a fresh registry per case.
4
+ import * as dsh1x from './dsh-1-x.js';
5
+
6
+ /** All host adapter factories, in registration order. */
7
+ export const hostAdapters = [
8
+ { supports: dsh1x.supports, name: dsh1x.name, create: dsh1x.create },
9
+ ];
10
+
11
+ /** Register every built-in host adapter onto a registry. */
12
+ export function registerHostAdapters(registry) {
13
+ for (const factory of hostAdapters) registry.register(factory);
14
+ return registry;
15
+ }
16
+
17
+ // Client adapter metadata for dsh 1.x (consumed by src/client.js).
18
+ //
19
+ // packageAliases serves TWO roles:
20
+ // 1. **Stable name → real name** (primary): plugins import from stable
21
+ // names like '@dshloader/ui-primitives' and the adapter maps them to
22
+ // the real dsh package name for this version. When dsh renames a
23
+ // package, only the adapter changes — plugin source and bundle stay
24
+ // the same.
25
+ // 2. **Old real name → new real name** (fallback): if a plugin bundle
26
+ // was built before adopting stable names and still has
27
+ // `require('@deepseek-ai/dsh-client-ui-primitives')` baked in, the
28
+ // adapter can map the old real name to the new real name as a
29
+ // transition measure.
30
+ export const clientAdapters = [
31
+ {
32
+ supports: dsh1x.supports,
33
+ name: dsh1x.name,
34
+ moduleAliases: {
35
+ // deep source import that breaks when dsh ships no `src/` (fix 1).
36
+ '@deepseek-ai/dsh-client-runtime/src/client/sessions/context-provenance.ts':
37
+ '@deepseek-ai/dsh-client-runtime/client',
38
+ // stable module name (design.md §3.3.3).
39
+ 'dsh/runtime/context-provenance': '@deepseek-ai/dsh-client-runtime/client',
40
+ },
41
+ // Stable package names → real dsh package names for dsh 1.x.
42
+ // Plugins import from @dsh-plugin/dsh-loader/* subpaths (e.g.
43
+ // '@dsh-plugin/dsh-loader/ui-primitives'); the __ModuleLoader__
44
+ // wrapper (installed by installClient) maps them to the real dsh
45
+ // package before hitting the module table. When dsh renames a
46
+ // package, only this table changes — plugin source and bundle stay
47
+ // the same.
48
+ packageAliases: {
49
+ // Client UI component libraries
50
+ '@dsh-plugin/dsh-loader/ui-primitives': '@deepseek-ai/dsh-client-ui-primitives',
51
+ '@dsh-plugin/dsh-loader/ui-slots': '@deepseek-ai/dsh-client-ui-slots',
52
+ '@dsh-plugin/dsh-loader/web-react': '@deepseek-ai/dsh-client-web-react',
53
+ '@dsh-plugin/dsh-loader/schema-form': '@deepseek-ai/dsh-client-schema-form',
54
+ '@dsh-plugin/dsh-loader/ui-settings': '@deepseek-ai/dsh-client-ui-settings/client',
55
+ // Client runtime
56
+ '@dsh-plugin/dsh-loader/runtime': '@deepseek-ai/dsh-client-runtime/client',
57
+ },
58
+ },
59
+ ];
package/src/api.js ADDED
@@ -0,0 +1,56 @@
1
+ // DshLoaderHostAPI construction (design.md §4.1 / §4.4).
2
+ //
3
+ // `createHostAPI` builds the `ctx.dshLoader` object exposed to other plugins.
4
+ // Each capability (settings / web / services) is constructed from the active
5
+ // adapter's overrides when present, otherwise from the default stable impl in
6
+ // src/services/*.js. The adapter's `apply()` is invoked separately by the
7
+ // bundle entry (src/index.js) so service aliases / bridge routes register via
8
+ // cordis effects and auto-recycle on fiber unload (design.md §4.4).
9
+ import { LOADER_VERSION } from './version.js';
10
+ import { createSettingsAPI } from './services/settings.js';
11
+ import { createWebAPI } from './services/web.js';
12
+ import { createServicesAPI } from './services/services.js';
13
+ import { installHostPackageAliases } from './adapters/dsh-1-x.js';
14
+
15
+ /**
16
+ * @param {{
17
+ * ctx: object,
18
+ * dshVersion: string,
19
+ * factory: { supports: string, name: string, create: Function },
20
+ * adapter?: object,
21
+ * exposeAllNamespaces?: boolean,
22
+ * whitelist?: Set<string>,
23
+ * hostPackageAliases?: Record<string, string>,
24
+ * }} opts
25
+ */
26
+ export function createHostAPI({ ctx, dshVersion, factory, adapter, exposeAllNamespaces = false, whitelist, hostPackageAliases = {} }) {
27
+ const base = {
28
+ version: LOADER_VERSION,
29
+ dshVersion,
30
+ adapterVersion: factory.supports,
31
+ };
32
+
33
+ // Adapters may override any capability (design.md §4.4 HostAdapter.{settings,
34
+ // web, services}); otherwise the default stable impl is used.
35
+ const settings = adapter?.settings ?? createSettingsAPI({ ctx, exposeAllNamespaces, whitelist });
36
+ const web = adapter?.web ?? createWebAPI({ ctx });
37
+ const services = adapter?.services ?? createServicesAPI({ ctx });
38
+
39
+ // Runtime host package-name alias registration. The adapter's apply()
40
+ // installs the static set at boot; this method lets plugins add more
41
+ // aliases at runtime (e.g. for packages the adapter didn't know about).
42
+ const runtimeHostAliases = new Map(Object.entries(hostPackageAliases));
43
+ const registerPackageAlias = (oldName, newName) => {
44
+ runtimeHostAliases.set(oldName, newName);
45
+ // Re-install the hook with the updated map.
46
+ installHostPackageAliases(Object.fromEntries(runtimeHostAliases));
47
+ };
48
+
49
+ return {
50
+ ...base,
51
+ settings,
52
+ web,
53
+ services,
54
+ registerPackageAlias,
55
+ };
56
+ }
package/src/client.js ADDED
@@ -0,0 +1,330 @@
1
+ // dshloader client bundle entry (design.md §3.5 / §4.3 / §7.3).
2
+ //
3
+ // Loaded in the `immediately` prefetch tier (package.json `dsh.client`).
4
+ // Responsibilities:
5
+ // 1. Mount `window.__dshLoader__` with `require` / `registerModuleAlias` /
6
+ // `rpc.settings.*`.
7
+ // 2. Register module-alias factories via `window.__ModuleLoader__.load` so
8
+ // deep source imports (fix 1) and stable module names resolve to the
9
+ // public runtime client entry.
10
+ // 3. When `exposeAllNamespaces` is on, install a fetch interceptor that
11
+ // merges non-whitelisted namespaces into `/api/settings.describe` and
12
+ // routes non-whitelisted writes through the host bridge, echoing the
13
+ // request `rpcId` in the `{ type: 'server-response', rpcId, result }`
14
+ // envelope (fix 5, path 2 — mirrors dsh-upstream-fixes/lib/client.js).
15
+ //
16
+ // The module exports an `installClient` function for testability and runs an
17
+ // IIFE at the bottom for the real browser bundle.
18
+ import { LOADER_VERSION, LOG_PREFIX } from './version.js';
19
+ import { clientAdapters } from './adapters/index.js';
20
+ import { BRIDGE_PREFIX } from './adapters/dsh-1-x.js';
21
+
22
+ export class ModuleNotFoundError extends Error {
23
+ constructor(specifier) {
24
+ super(`${LOG_PREFIX} module not found: ${specifier}`);
25
+ this.name = 'ModuleNotFoundError';
26
+ this.specifier = specifier;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Pick the client adapter for the given dsh version. v1 ships a single
32
+ * client adapter (dsh 1.x); when more are added, swap this for a semver-based
33
+ * selection mirroring the host AdapterRegistry.
34
+ */
35
+ function pickClientAdapter(dshVersion) {
36
+ return clientAdapters[0];
37
+ }
38
+
39
+ /**
40
+ * Build the `window.__dshLoader__` API object.
41
+ * @param {{
42
+ * dshVersion?: string,
43
+ * adapterVersion?: string,
44
+ * moduleAliases?: Record<string,string>,
45
+ * requireImpl?: (spec: string) => any,
46
+ * fetchBridge?: { describe: () => Promise<any>, write: (mode:string, payload:object) => Promise<any> },
47
+ * clientCtx?: object, // cordis client context (for services.get)
48
+ * }} opts
49
+ */
50
+ export function createClientAPI(opts = {}) {
51
+ const aliases = new Map(Object.entries(opts.moduleAliases ?? {}));
52
+ const requireImpl = opts.requireImpl ?? ((spec) => {
53
+ throw new ModuleNotFoundError(spec);
54
+ });
55
+ const clientCtx = opts.clientCtx;
56
+ // Reuse the Map from installClient when provided so registerPackageAlias
57
+ // mutations are visible to the __ModuleLoader__ wrapper. Otherwise create
58
+ // a fresh Map from a plain object.
59
+ const packageAliases = opts.packageAliases instanceof Map
60
+ ? opts.packageAliases
61
+ : new Map(Object.entries(opts.packageAliases ?? {}));
62
+
63
+ const api = {
64
+ version: LOADER_VERSION,
65
+ dshVersion: opts.dshVersion,
66
+ adapterVersion: opts.adapterVersion,
67
+
68
+ require(specifier) {
69
+ if (typeof specifier !== 'string') throw new ModuleNotFoundError(String(specifier));
70
+ const target = aliases.get(specifier);
71
+ if (target === undefined) {
72
+ throw new ModuleNotFoundError(specifier);
73
+ }
74
+ return requireImpl(target);
75
+ },
76
+
77
+ registerModuleAlias(alias, target) {
78
+ aliases.set(alias, target);
79
+ },
80
+
81
+ /**
82
+ * Register a package-name alias for the client module loader's
83
+ * require(). When a plugin's bundle calls require('@old/pkg-name'),
84
+ * the loader's wrapped require remaps it to '@new/pkg-name' before
85
+ * hitting the module table. This lets dsh rename client packages
86
+ * across versions without forcing plugin bundles to rebuild.
87
+ */
88
+ registerPackageAlias(oldName, newName) {
89
+ packageAliases.set(oldName, newName);
90
+ },
91
+
92
+ /**
93
+ * Read a client-side cordis service by name.
94
+ * Proxies to `clientCtx.get(name)` — the same ctx.get plugins use
95
+ * inside their client `apply(ctx)`, but exposed through the stable
96
+ * dshloader surface so plugins don't depend on the cordis context
97
+ * shape directly.
98
+ *
99
+ * Only available when dshloader's client apply() received a ctx
100
+ * (cordis client boot). Returns undefined when ctx is not wired.
101
+ */
102
+ services: {
103
+ get(name) {
104
+ if (clientCtx === undefined || typeof clientCtx.get !== 'function') return undefined;
105
+ return clientCtx.get(name);
106
+ },
107
+ },
108
+
109
+ rpc: opts.fetchBridge
110
+ ? {
111
+ settings: {
112
+ describe: () => opts.fetchBridge.describe(),
113
+ update: (ns, section) => opts.fetchBridge.write('update', { ns, section }),
114
+ replace: (ns, section) => opts.fetchBridge.write('replace', { ns, section }),
115
+ mutate: (ns, ops) => opts.fetchBridge.write('mutate', { ns, ops }),
116
+ },
117
+ }
118
+ : undefined,
119
+ };
120
+
121
+ return api;
122
+ }
123
+
124
+ /**
125
+ * Install dshloader into a browser-like environment.
126
+ * @param {{
127
+ * window?: any,
128
+ * dshVersion?: string,
129
+ * exposeAllNamespaces?: boolean,
130
+ * requireImpl?: (spec: string) => any,
131
+ * hostBridgePrefix?: string,
132
+ * clientCtx?: object, // cordis client context (passed from apply(ctx))
133
+ * }} [opts]
134
+ */
135
+ export function installClient(opts = {}) {
136
+ const win = opts.window ?? (typeof window !== 'undefined' ? window : undefined);
137
+ if (win === undefined) return undefined;
138
+
139
+ const dshVersion = opts.dshVersion ?? win.__DSHLOADER_VERSION__ ?? undefined;
140
+ const adapter = pickClientAdapter(dshVersion);
141
+ const moduleAliases = { ...(adapter.moduleAliases ?? {}) };
142
+ // Merge adapter-declared package aliases with any passed via opts (opts
143
+ // win on conflict, so tests / runtime can override adapter defaults).
144
+ const packageAliases = new Map(Object.entries(adapter.packageAliases ?? {}));
145
+ if (opts.packageAliases) {
146
+ for (const [k, v] of Object.entries(opts.packageAliases)) packageAliases.set(k, v);
147
+ }
148
+
149
+ // fetch bridge (only meaningful when exposeAllNamespaces is on).
150
+ const exposeAllNamespaces = Boolean(
151
+ opts.exposeAllNamespaces ?? win.__DSHLOADER_CONFIG__?.exposeAllNamespaces,
152
+ );
153
+ const bridgePrefix = opts.hostBridgePrefix ?? BRIDGE_PREFIX;
154
+
155
+ const fetchBridge = exposeAllNamespaces
156
+ ? {
157
+ describe: () => win.fetch(`${bridgePrefix}/settings/describe`, { headers: { accept: 'application/json' } }).then((r) => r.json()),
158
+ write: (mode, payload) =>
159
+ win.fetch(`${bridgePrefix}/settings/${mode}`, {
160
+ method: 'POST',
161
+ headers: { 'content-type': 'application/json' },
162
+ body: JSON.stringify(payload),
163
+ }).then((r) => r.json()),
164
+ }
165
+ : undefined;
166
+
167
+ const api = createClientAPI({
168
+ dshVersion,
169
+ adapterVersion: adapter.supports,
170
+ moduleAliases,
171
+ packageAliases,
172
+ requireImpl: opts.requireImpl ?? ((spec) => win.__dshNativeRequire__?.(spec)),
173
+ fetchBridge,
174
+ clientCtx: opts.clientCtx,
175
+ });
176
+ win.__dshLoader__ = api;
177
+
178
+ // Wrap __ModuleLoader__.load so every factory's require() function
179
+ // applies package-name aliases before hitting the module table. This
180
+ // must happen BEFORE registering module-alias factories below, because
181
+ // those factories also receive the wrapped require.
182
+ const loader = win.__ModuleLoader__;
183
+ if (loader && typeof loader.load === 'function') {
184
+ const originalLoad = loader.load.bind(loader);
185
+ loader.load = function dshloaderLoad(handoff) {
186
+ const wrappedFactory = (require) => {
187
+ const aliasedRequire = (spec) => {
188
+ const mapped = packageAliases.get(spec);
189
+ return require(mapped ?? spec);
190
+ };
191
+ return handoff.factory(aliasedRequire);
192
+ };
193
+ return originalLoad({ id: handoff.id, factory: wrappedFactory });
194
+ };
195
+ }
196
+
197
+ // Register module-alias factories with the client module loader (fix 1).
198
+ if (loader && typeof loader.load === 'function') {
199
+ for (const [aliasId, target] of Object.entries(moduleAliases)) {
200
+ loader.load({
201
+ id: aliasId,
202
+ factory: (require) => {
203
+ const mod = require(target);
204
+ // Preserve the deep import's expected named export shape.
205
+ if (aliasId.endsWith('context-provenance.ts') || aliasId.endsWith('context-provenance')) {
206
+ return { contextProvenance: mod.contextProvenance };
207
+ }
208
+ return mod;
209
+ },
210
+ });
211
+ }
212
+ }
213
+
214
+ if (exposeAllNamespaces) {
215
+ installSettingsFetchInterceptor(win, bridgePrefix);
216
+ console.warn(`${LOG_PREFIX} exposeAllNamespaces enabled: bypassing official settings whitelist`);
217
+ }
218
+
219
+ return api;
220
+ }
221
+
222
+ /**
223
+ * Fetch interceptor for settings namespace whitelist bypass (fix 5, path 2).
224
+ * Mirrors dsh-upstream-fixes/lib/client.js (127-223).
225
+ *
226
+ * - /api/settings.describe: merge non-whitelisted namespaces from the host
227
+ * bridge into the official response (rpcId/official fields preserved).
228
+ * - /api/settings.{update,mutate,replace}: route writes for namespaces the
229
+ * official proxy does NOT expose through the host bridge, and rebuild the
230
+ * response envelope as `{ type: 'server-response', rpcId, result }` so the
231
+ * official client can correlate the response with the request.
232
+ */
233
+ export function installSettingsFetchInterceptor(win, bridgePrefix = BRIDGE_PREFIX) {
234
+ if (typeof win.fetch !== 'function') return () => {};
235
+ const originalFetch = win.fetch;
236
+ /** Namespaces the official proxy itself exposed (learned from describe). */
237
+ const officialExposed = new Set();
238
+
239
+ win.fetch = async function dshloaderFetch(input, init) {
240
+ let pathname = '';
241
+ try {
242
+ pathname = new URL(typeof input === 'string' ? input : input.url, win.location?.origin ?? 'http://localhost').pathname;
243
+ } catch {
244
+ pathname = String(input).split('?')[0];
245
+ }
246
+ const method = (init?.method ?? 'GET').toUpperCase();
247
+
248
+ // describe: merge bridge namespaces into the official response.
249
+ if (pathname === '/api/settings.describe') {
250
+ const response = await originalFetch(input, init);
251
+ try {
252
+ const body = await response.clone().json();
253
+ const namespaces = body?.result?.value?.namespaces;
254
+ if (!Array.isArray(namespaces)) return response;
255
+ for (const row of namespaces) {
256
+ if (typeof row?.ns === 'string') officialExposed.add(row.ns);
257
+ }
258
+ const extra = await originalFetch(`${bridgePrefix}/settings/describe`, {
259
+ headers: { accept: 'application/json' },
260
+ });
261
+ const extraBody = await extra.json();
262
+ if (extraBody?.ok !== true || !Array.isArray(extraBody.namespaces)) return response;
263
+ const seen = new Set(namespaces.map((row) => row.ns));
264
+ const merged = [...namespaces, ...extraBody.namespaces.filter((row) => !seen.has(row.ns))];
265
+ return new win.Response(
266
+ JSON.stringify({ ...body, result: { ...body.result, value: { ...body.result.value, namespaces: merged } } }),
267
+ { status: 200, headers: { 'content-type': 'application/json' } },
268
+ );
269
+ } catch {
270
+ return response;
271
+ }
272
+ }
273
+
274
+ // writes: route non-whitelisted namespaces through the bridge, echo rpcId.
275
+ if (
276
+ (pathname === '/api/settings.update' || pathname === '/api/settings.mutate' || pathname === '/api/settings.replace') &&
277
+ method === 'POST'
278
+ ) {
279
+ let rpcId = null;
280
+ let payload = null;
281
+ try {
282
+ const parsed = JSON.parse(String(init?.body ?? '{}'));
283
+ rpcId = parsed.rpcId;
284
+ payload = parsed.payload;
285
+ } catch {
286
+ /* fall through to the original endpoint */
287
+ }
288
+ const ns = typeof payload?.ns === 'string' ? payload.ns : '';
289
+ if (rpcId !== null && ns !== '' && officialExposed.size > 0 && !officialExposed.has(ns)) {
290
+ try {
291
+ const mode = pathname.slice('/api/settings.'.length);
292
+ const res = await originalFetch(`${bridgePrefix}/settings/${mode}`, {
293
+ method: 'POST',
294
+ headers: { 'content-type': 'application/json' },
295
+ body: JSON.stringify(payload),
296
+ });
297
+ const body = await res.json();
298
+ if (body?.result) {
299
+ return new win.Response(
300
+ JSON.stringify({ type: 'server-response', rpcId, result: body.result }),
301
+ { status: 200, headers: { 'content-type': 'application/json' } },
302
+ );
303
+ }
304
+ } catch {
305
+ /* fall back to the original endpoint */
306
+ }
307
+ }
308
+ }
309
+
310
+ return originalFetch(input, init);
311
+ };
312
+
313
+ return () => {
314
+ win.fetch = originalFetch;
315
+ };
316
+ }
317
+
318
+ // ── cordis client-plugin entry ─────────────────────────────────────────
319
+ // dsh's client boot applies every registered bundle as a cordis plugin:
320
+ // the module must expose `apply` (third-party plugins / dsh-client-runtime
321
+ // all do `exports.apply = apply`). Without it the client boot fails with
322
+ // "invalid plugin, expect function or object with an \"apply\" method".
323
+ // `installClient` mounts window.__dshLoader__, registers module aliases,
324
+ // wires the client cordis ctx for `services.get`, and (when opted in)
325
+ // installs the settings fetch interceptor.
326
+ export const name = '@dsh-plugin/dsh-loader'
327
+ export const inject = []
328
+ export function apply(ctx) {
329
+ if (typeof window !== 'undefined') installClient({ window, clientCtx: ctx });
330
+ }