dsh-skill-hub 0.3.24 → 0.3.25

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.
@@ -7,6 +7,28 @@ import { type CatalogResponse, type ConfigResponse, type CreateRequest, type Cre
7
7
  export declare class SkillHubApiError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
+ /**
11
+ * Resolve one route path against the page's base.
12
+ *
13
+ * Every route in this family is registered on the server as a root-absolute
14
+ * path (`/api/skill-hub/...`), which is right there but wrong in a browser
15
+ * behind a reverse proxy that owns a path prefix: the page loads from
16
+ * `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
17
+ * so the gateway answers 404 HTML and `readJson` reports "invalid JSON
18
+ * response" (issue #18). Resolving against `document.baseURI` is the host's own
19
+ * convention — `dsh-client-connection` opens its browser carrier with
20
+ * `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
21
+ * exactly the same absolute path while a prefixed one keeps its prefix, and the
22
+ * gateway strips it before forwarding: the server still receives
23
+ * `/api/skill-hub/*`.
24
+ *
25
+ * `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
26
+ * routes on the server), which is why the prefix is applied here at the single
27
+ * outbound boundary rather than in the constants.
28
+ * @param path - one route path, root-absolute unless it is already a full URL.
29
+ * @returns the URL to fetch.
30
+ */
31
+ export declare function resolveApiUrl(path: string): string;
10
32
  /** The browser half's only data entry point. */
11
33
  export declare class SkillHubApi {
12
34
  /** One GET round trip (query already encoded by the caller). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Root-absolute route paths must resolve against the page's base, not the site
3
+ * root: behind a reverse proxy that owns a path prefix (`https://host/app/dsh/`)
4
+ * a root-absolute URL loses the prefix and the gateway answers 404 HTML — the
5
+ * `HTTP 404: invalid JSON response` banner in issue #18.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { SkillHubApi, resolveApiUrl } from './api.ts'
10
+
11
+ describe('resolveApiUrl (issue #18)', () => {
12
+ afterEach(() => { vi.unstubAllGlobals() })
13
+
14
+ it('resolves to the site root for a plain deployment', () => {
15
+ vi.stubGlobal('document', { baseURI: 'http://127.0.0.1:3080/' })
16
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('http://127.0.0.1:3080/api/skill-hub/catalog')
17
+ })
18
+
19
+ it('keeps a reverse proxy path prefix', () => {
20
+ vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
21
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
22
+ })
23
+
24
+ it('passes full URLs through and tolerates a missing document (SSR/tests)', () => {
25
+ expect(resolveApiUrl('https://cdn.example.com/x.json')).toBe('https://cdn.example.com/x.json')
26
+ expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('/api/skill-hub/catalog')
27
+ })
28
+ })
29
+
30
+ describe('SkillHubApi outbound boundary (issue #18)', () => {
31
+ afterEach(() => { vi.unstubAllGlobals() })
32
+
33
+ it('sends every request through the resolved URL, prefix included', async () => {
34
+ vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
35
+ const seen: string[] = []
36
+ vi.stubGlobal('fetch', async (url: string) => {
37
+ seen.push(String(url))
38
+ return new Response(JSON.stringify({ ok: true, skills: [], disabled: [], diagnostics: [] }), {
39
+ status: 200,
40
+ headers: { 'content-type': 'application/json' },
41
+ })
42
+ })
43
+
44
+ const api = new SkillHubApi()
45
+ await api.catalog()
46
+
47
+ // One outbound path for the whole family: the prefix is applied once, here.
48
+ expect(seen).toHaveLength(1)
49
+ expect(seen[0]).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
50
+ })
51
+ })
package/src/client/api.ts CHANGED
@@ -83,6 +83,37 @@ async function readJson<T>(response: Response): Promise<T> {
83
83
  return body as T
84
84
  }
85
85
 
86
+ /**
87
+ * Resolve one route path against the page's base.
88
+ *
89
+ * Every route in this family is registered on the server as a root-absolute
90
+ * path (`/api/skill-hub/...`), which is right there but wrong in a browser
91
+ * behind a reverse proxy that owns a path prefix: the page loads from
92
+ * `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
93
+ * so the gateway answers 404 HTML and `readJson` reports "invalid JSON
94
+ * response" (issue #18). Resolving against `document.baseURI` is the host's own
95
+ * convention — `dsh-client-connection` opens its browser carrier with
96
+ * `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
97
+ * exactly the same absolute path while a prefixed one keeps its prefix, and the
98
+ * gateway strips it before forwarding: the server still receives
99
+ * `/api/skill-hub/*`.
100
+ *
101
+ * `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
102
+ * routes on the server), which is why the prefix is applied here at the single
103
+ * outbound boundary rather than in the constants.
104
+ * @param path - one route path, root-absolute unless it is already a full URL.
105
+ * @returns the URL to fetch.
106
+ */
107
+ export function resolveApiUrl(path: string): string {
108
+ if (!path.startsWith('/')) return path
109
+ if (typeof document === 'undefined') return path
110
+ try {
111
+ return new URL(path.slice(1), document.baseURI).href
112
+ } catch {
113
+ return path
114
+ }
115
+ }
116
+
86
117
  /**
87
118
  * Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
88
119
  * host wedged) must never leave the panel stuck on "loading" forever — it
@@ -92,7 +123,7 @@ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms
92
123
  const controller = new AbortController()
93
124
  const timer = setTimeout(() => controller.abort(), ms)
94
125
  try {
95
- return await fetch(input, { ...init, signal: controller.signal })
126
+ return await fetch(typeof input === 'string' ? resolveApiUrl(input) : input, { ...init, signal: controller.signal })
96
127
  } finally {
97
128
  clearTimeout(timer)
98
129
  }