@assistant-ui/vscode 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +159 -2
- package/dist/external-url.d.ts +3 -0
- package/dist/external-url.d.ts.map +1 -0
- package/dist/external-url.js +19 -0
- package/dist/external-url.js.map +1 -0
- package/dist/host/html.d.ts +65 -0
- package/dist/host/html.d.ts.map +1 -0
- package/dist/host/html.js +126 -0
- package/dist/host/html.js.map +1 -0
- package/dist/host/router.d.ts +24 -0
- package/dist/host/router.d.ts.map +1 -0
- package/dist/host/router.js +166 -0
- package/dist/host/router.js.map +1 -0
- package/dist/host/serve.d.ts +34 -0
- package/dist/host/serve.d.ts.map +1 -0
- package/dist/host/serve.js +93 -0
- package/dist/host/serve.js.map +1 -0
- package/dist/host.d.ts +5 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +4 -0
- package/dist/protocol.d.ts +46 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +30 -0
- package/dist/protocol.js.map +1 -0
- package/dist/route-url.d.ts +3 -0
- package/dist/route-url.d.ts.map +1 -0
- package/dist/route-url.js +7 -0
- package/dist/route-url.js.map +1 -0
- package/dist/webview/csp-nonce.d.ts +6 -0
- package/dist/webview/csp-nonce.d.ts.map +1 -0
- package/dist/webview/csp-nonce.js +12 -0
- package/dist/webview/csp-nonce.js.map +1 -0
- package/dist/webview/fetch.d.ts +14 -0
- package/dist/webview/fetch.d.ts.map +1 -0
- package/dist/webview/fetch.js +164 -0
- package/dist/webview/fetch.js.map +1 -0
- package/dist/webview/id.d.ts +2 -0
- package/dist/webview/id.d.ts.map +1 -0
- package/dist/webview/id.js +5 -0
- package/dist/webview/id.js.map +1 -0
- package/dist/webview/links.d.ts +14 -0
- package/dist/webview/links.d.ts.map +1 -0
- package/dist/webview/links.js +44 -0
- package/dist/webview/links.js.map +1 -0
- package/dist/webview/rpc.d.ts +3 -0
- package/dist/webview/rpc.d.ts.map +1 -0
- package/dist/webview/rpc.js +32 -0
- package/dist/webview/rpc.js.map +1 -0
- package/dist/webview/storage.d.ts +8 -0
- package/dist/webview/storage.d.ts.map +1 -0
- package/dist/webview/storage.js +24 -0
- package/dist/webview/storage.js.map +1 -0
- package/dist/webview/vscode-api.d.ts +11 -0
- package/dist/webview/vscode-api.d.ts.map +1 -0
- package/dist/webview/vscode-api.js +22 -0
- package/dist/webview/vscode-api.js.map +1 -0
- package/dist/webview.d.ts +7 -0
- package/dist/webview.d.ts.map +1 -0
- package/dist/webview.js +7 -0
- package/package.json +87 -3
- package/src/external-url.ts +19 -0
- package/src/host/html.ts +224 -0
- package/src/host/router.ts +253 -0
- package/src/host/serve.ts +150 -0
- package/src/host.ts +27 -0
- package/src/protocol.ts +131 -0
- package/src/route-url.ts +6 -0
- package/src/theme.css +327 -0
- package/src/webview/csp-nonce.ts +10 -0
- package/src/webview/fetch.ts +210 -0
- package/src/webview/id.ts +4 -0
- package/src/webview/links.ts +56 -0
- package/src/webview/rpc.ts +35 -0
- package/src/webview/storage.ts +24 -0
- package/src/webview/vscode-api.ts +31 -0
- package/src/webview.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentbaseAI Inc.
|
|
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,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @assistant-ui/vscode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run [assistant-ui](https://www.assistant-ui.com/) inside a VS Code extension webview.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @assistant-ui/vscode
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Start a new extension with `npx assistant-ui@latest create my-extension -t vscode`, or follow the [VS Code guide](https://www.assistant-ui.com/docs/guides/vscode) to add it to an existing one.
|
|
10
|
+
|
|
11
|
+
A webview cannot reach your backend with `fetch`, so this package tunnels `fetch` over the webview's `postMessage` channel. Your route handlers (`(req: Request) => Response`) run unchanged in the extension host, and their responses stream back to the webview.
|
|
12
|
+
|
|
13
|
+
## Entry points
|
|
14
|
+
|
|
15
|
+
- `@assistant-ui/vscode/host` runs in the extension host. It never imports the `vscode` module.
|
|
16
|
+
- `@assistant-ui/vscode/webview` runs in the webview bundle.
|
|
17
|
+
|
|
18
|
+
## Extension host
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { serveWebviewRoutes } from "@assistant-ui/vscode/host";
|
|
22
|
+
import { POST } from "./api/chat";
|
|
23
|
+
|
|
24
|
+
export function resolveWebviewView(view: vscode.WebviewView) {
|
|
25
|
+
view.webview.options = { enableScripts: true };
|
|
26
|
+
const server = serveWebviewRoutes(view.webview, {
|
|
27
|
+
"/api/chat": { POST },
|
|
28
|
+
});
|
|
29
|
+
view.onDidDispose(() => server.dispose());
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Unknown paths answer `404`, unsupported methods `405`, and a handler that throws `500`. Aborting the request in the webview aborts `req.signal` in the handler, and disposing the server rejects the requests still in flight. `vscodeFetch` only reaches these routes, so a URL on another `http:` or `https:` origin rejects with a `TypeError`, and the host answers a request for such a URL with an error instead of routing it. `serveWebviewRoutes` serves routes only: host calls from `installLinkInterceptor` or `createVSCodeStorage` reject in the webview, so use [`serveWebviewHost`](#host-services) when the webview uses them.
|
|
34
|
+
|
|
35
|
+
## Webview
|
|
36
|
+
|
|
37
|
+
When a reloaded page first acquires the VS Code API, it notifies the host to abort requests from the previous page, including response streams still in progress. Creating another fetch client in the same page does not cancel active requests.
|
|
38
|
+
|
|
39
|
+
Pass `vscodeFetch` to any runtime that takes a `fetch`:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
|
|
43
|
+
import { vscodeFetch } from "@assistant-ui/vscode/webview";
|
|
44
|
+
|
|
45
|
+
const runtime = useChatRuntime({
|
|
46
|
+
transport: new AssistantChatTransport({ api: "/api/chat", fetch: vscodeFetch }),
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Host services
|
|
51
|
+
|
|
52
|
+
`serveWebviewHost` serves the same `routes` as `serveWebviewRoutes` and also answers the webview's calls into the host. Use it in place of `serveWebviewRoutes`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { serveWebviewHost } from "@assistant-ui/vscode/host";
|
|
56
|
+
|
|
57
|
+
const host = serveWebviewHost(view.webview, {
|
|
58
|
+
routes: { "/api/chat": { POST } },
|
|
59
|
+
openExternal: (url) => vscode.env.openExternal(url as unknown as vscode.Uri),
|
|
60
|
+
storage: context.globalState,
|
|
61
|
+
});
|
|
62
|
+
view.onDidDispose(() => host.dispose());
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### External links
|
|
66
|
+
|
|
67
|
+
A webview cannot navigate away or open windows. `installLinkInterceptor()` catches clicks on absolute `http:`, `https:`, and `mailto:` links, plus `window.open` calls, and asks the host to open them with `openExternal`. The host checks the scheme again against `externalSchemes` before calling it. Keep `command:`, `vscode:`, and `file:` out of `externalSchemes`, because a link in model output could then run a command or open a local file. Relative links, `#anchors`, and clicks the app already `preventDefault`ed are left alone.
|
|
68
|
+
|
|
69
|
+
Pass the URL string itself to `vscode.env.openExternal`, as above. VS Code opens a string as it is, but it re-encodes a `vscode.Uri.parse(url)` result, which decodes characters such as `%2B` and `%26` and breaks signed URLs.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { installLinkInterceptor } from "@assistant-ui/vscode/webview";
|
|
73
|
+
|
|
74
|
+
installLinkInterceptor();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Thread persistence
|
|
78
|
+
|
|
79
|
+
`createVSCodeStorage()` stores threads in the Memento passed as `storage` (`context.globalState` or `context.workspaceState`), so they survive reloading the window. Its keys are namespaced by `storagePrefix` (default `"@assistant-ui/vscode:"`).
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { createLocalStorageAdapter } from "@assistant-ui/core/react";
|
|
83
|
+
import { useRemoteThreadListRuntime } from "@assistant-ui/react";
|
|
84
|
+
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/ai-sdk";
|
|
85
|
+
import { createVSCodeStorage, vscodeFetch } from "@assistant-ui/vscode/webview";
|
|
86
|
+
|
|
87
|
+
const threads = createLocalStorageAdapter({ storage: createVSCodeStorage() });
|
|
88
|
+
const transport = new AssistantChatTransport({ api: "/api/chat", fetch: vscodeFetch });
|
|
89
|
+
|
|
90
|
+
const runtime = useRemoteThreadListRuntime({
|
|
91
|
+
adapter: threads,
|
|
92
|
+
runtimeHook: function useThreadRuntime() {
|
|
93
|
+
return useChatRuntime({ transport });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`acquireVsCodeApi()` may only be called once per webview. Use `getVSCodeApi()` wherever your webview code needs the API.
|
|
99
|
+
|
|
100
|
+
## Webview HTML and Content Security Policy
|
|
101
|
+
|
|
102
|
+
`renderWebviewHtml` returns the webview's HTML with a Content Security Policy, a fresh nonce on every script tag, and your bundle's URIs converted with `asWebviewUri`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { renderWebviewHtml } from "@assistant-ui/vscode/host";
|
|
106
|
+
|
|
107
|
+
const dist = vscode.Uri.joinPath(context.extensionUri, "dist");
|
|
108
|
+
view.webview.options = { enableScripts: true, localResourceRoots: [dist] };
|
|
109
|
+
view.webview.html = renderWebviewHtml(view.webview, {
|
|
110
|
+
scripts: [vscode.Uri.joinPath(dist, "webview.js")],
|
|
111
|
+
styles: [vscode.Uri.joinPath(dist, "webview.css")],
|
|
112
|
+
title: "Chat",
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The strict policy (the default) is:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
default-src 'none'; script-src 'nonce-…'; style-src <cspSource> 'nonce-…';
|
|
120
|
+
img-src <cspSource> blob: data:; media-src <cspSource> blob: data:;
|
|
121
|
+
font-src <cspSource> data:; connect-src <cspSource>; frame-src 'none'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- `csp: "relaxed"` replaces the style nonce with `'unsafe-inline'`, for libraries that inject `<style>` tags without a nonce. Browsers ignore `'unsafe-inline'` next to a nonce, so the nonce is dropped from `style-src`. `script-src` keeps it.
|
|
125
|
+
- `connectSrc`, `frameSrc`, and `scriptSrc` append sources, one host or scheme per entry. Code-split chunks should need no `scriptSrc` entry, because a module script's imports, `import()` included, inherit its nonce. The VS Code test bed only covers a single classic bundle, so check the webview's developer tools for CSP errors if you split.
|
|
126
|
+
- `imgSrc` and `mediaSrc` append sources to `img-src` and `media-src`. Remote images and media are blocked by default, because an image URL in model output loads as soon as the message renders and can carry chat data away. Allow the hosts you trust, such as `https://icons.duckduckgo.com` for the favicons of the `Sources` component (which falls back to an icon when they are blocked), or `"https:"` for every remote image.
|
|
127
|
+
- `wasmUnsafeEval: true` adds `'wasm-unsafe-eval'` for WebAssembly. `'unsafe-eval'` is never added.
|
|
128
|
+
- `surface` sets `data-aui-vscode-surface` on `<body>` for the theme, `rootId` names the mount element (`"root"`, or `null` for none), and `scriptType: "classic"` emits deferred classic scripts instead of modules.
|
|
129
|
+
|
|
130
|
+
`createWebviewCsp(webview, { nonce })` returns only the policy, and `createCspNonce()` a nonce, if you write your own HTML.
|
|
131
|
+
|
|
132
|
+
The nonce is also written to `<meta property="csp-nonce">`, the tag Vite reads. In the webview, `getCspNonce()` from `@assistant-ui/vscode/webview` returns it for libraries that inject tags at runtime.
|
|
133
|
+
|
|
134
|
+
## Theme
|
|
135
|
+
|
|
136
|
+
`@assistant-ui/vscode/theme.css` maps the shadcn tokens used by assistant-ui's components (`--background`, `--primary`, `--muted-foreground`, `--border`, `--ring`, `--sidebar-*`, `--chart-*`, `--radius`, and the rest) to VS Code's `--vscode-*` theme variables, so the webview restyles itself when the user switches colour theme. It also re-points Tailwind's `dark:` variant at VS Code's `vscode-dark` and dark high-contrast body classes, sets `color-scheme`, uses the VS Code UI and editor fonts for `font-sans` and `font-mono`, and adds contrast borders and focus outlines under high-contrast themes.
|
|
137
|
+
|
|
138
|
+
`--muted` and `--secondary` mix 6% and 12% of `--foreground` into `--background`, because no VS Code variable stays distinct from every surface: `--vscode-input-background` equals the editor background in Light Modern, and `--vscode-button-secondaryBackground` is transparent in Dark Modern. Under high-contrast themes, `--primary` is `--vscode-textLink-foreground` with `--background` as its foreground, since Dark High Contrast sets `--vscode-button-background` to its black background, and user message bubbles, the composer quote, and the active thread in the thread list get a contrast outline.
|
|
139
|
+
|
|
140
|
+
Import it after Tailwind:
|
|
141
|
+
|
|
142
|
+
```css
|
|
143
|
+
@import "tailwindcss";
|
|
144
|
+
@import "tw-animate-css";
|
|
145
|
+
@import "@assistant-ui/vscode/theme.css";
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If you keep an existing `globals.css` (such as the one from the assistant-ui templates), import the theme at the end of the file instead. Tailwind keeps the last `@custom-variant dark` and the last `@theme` value for a key, so a theme imported above the template's own `@custom-variant dark (&:is(.dark *))` and `--font-sans` leaves those in charge. The colour tokens are set on `body`, so they override a template's `:root` values in either position.
|
|
149
|
+
|
|
150
|
+
To override a token, set it on `body`:
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
body {
|
|
154
|
+
--radius: 0.25rem;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The background follows a webview view in the sidebar by default. Set `data-aui-vscode-surface` on `<html>` or `<body>` to `"editor"` for a webview panel in an editor tab, or to `"panel"` for a view in the bottom panel.
|
|
159
|
+
|
|
160
|
+
VS Code injects a default stylesheet into every webview (body padding, link, `code`, `kbd` and block quote colours, focus outlines), unlayered in older releases and as `@layer vscode-default` in newer ones. The theme resets those properties in either case, so Tailwind's preflight and your utilities decide them.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"external-url.d.ts","sourceRoot":"","sources":["../src/external-url.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAIrD,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,OAAO,EACd,SAAS,SAAS,MAAM,EAAE,KACzB,MAAM,GAAG,IASX,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/external-url.ts
|
|
2
|
+
const DEFAULT_EXTERNAL_SCHEMES = [
|
|
3
|
+
"http:",
|
|
4
|
+
"https:",
|
|
5
|
+
"mailto:"
|
|
6
|
+
];
|
|
7
|
+
const parseExternalUrl = (value, schemes) => {
|
|
8
|
+
if (typeof value !== "string" && !(value instanceof URL)) return null;
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(value);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return schemes.includes(url.protocol) ? url.href : null;
|
|
16
|
+
};
|
|
17
|
+
export { DEFAULT_EXTERNAL_SCHEMES, parseExternalUrl };
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=external-url.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"external-url.js","names":[],"sources":["../src/external-url.ts"],"sourcesContent":["export const DEFAULT_EXTERNAL_SCHEMES: readonly string[] = [\n \"http:\",\n \"https:\",\n \"mailto:\",\n];\n\nexport const parseExternalUrl = (\n value: unknown,\n schemes: readonly string[],\n): string | null => {\n if (typeof value !== \"string\" && !(value instanceof URL)) return null;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n return schemes.includes(url.protocol) ? url.href : null;\n};\n"],"mappings":";AAAA,MAAa,2BAA8C;CACzD;CACA;CACA;AACF;AAEA,MAAa,oBACX,OACA,YACkB;CAClB,IAAI,OAAO,UAAU,YAAY,EAAE,iBAAiB,MAAM,OAAO;CACjE,IAAI;CACJ,IAAI;EACF,MAAM,IAAI,IAAI,KAAK;CACrB,QAAQ;EACN,OAAO;CACT;CACA,OAAO,QAAQ,SAAS,IAAI,QAAQ,IAAI,IAAI,OAAO;AACrD"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type WebviewCspSource = {
|
|
2
|
+
/** A source list, such as VS Code's `'self' https://*.vscode-cdn.net`. */
|
|
3
|
+
readonly cspSource: string;
|
|
4
|
+
};
|
|
5
|
+
export type WebviewResourceLike<U> = WebviewCspSource & {
|
|
6
|
+
asWebviewUri(uri: U): {
|
|
7
|
+
toString(): string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export type WebviewCspMode = "strict" | "relaxed";
|
|
11
|
+
export type WebviewCspOptions = {
|
|
12
|
+
nonce: string;
|
|
13
|
+
/**
|
|
14
|
+
* `"relaxed"` swaps the style nonce for `'unsafe-inline'`, for libraries
|
|
15
|
+
* that inject `<style>` tags without a nonce.
|
|
16
|
+
*/
|
|
17
|
+
csp?: WebviewCspMode;
|
|
18
|
+
/**
|
|
19
|
+
* Extra `script-src` sources, one host or scheme per entry. Code-split
|
|
20
|
+
* chunks need none: a module script's imports inherit its nonce.
|
|
21
|
+
*/
|
|
22
|
+
scriptSrc?: readonly string[];
|
|
23
|
+
connectSrc?: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Extra `img-src` sources, such as `"https:"`. Remote images are blocked by
|
|
26
|
+
* default, because an image URL in model output can carry chat data away.
|
|
27
|
+
*/
|
|
28
|
+
imgSrc?: readonly string[];
|
|
29
|
+
/** Extra `media-src` sources, blocked by default like `imgSrc`. */
|
|
30
|
+
mediaSrc?: readonly string[];
|
|
31
|
+
frameSrc?: readonly string[];
|
|
32
|
+
/** Adds `'wasm-unsafe-eval'` to `script-src` for WebAssembly modules. */
|
|
33
|
+
wasmUnsafeEval?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type WebviewSurface = "sidebar" | "editor" | "panel";
|
|
36
|
+
export type RenderWebviewHtmlOptions<U> = Omit<WebviewCspOptions, "nonce"> & {
|
|
37
|
+
scripts: readonly U[];
|
|
38
|
+
styles?: readonly U[];
|
|
39
|
+
title?: string;
|
|
40
|
+
lang?: string;
|
|
41
|
+
/** Defaults to a fresh random nonce per render. */
|
|
42
|
+
nonce?: string;
|
|
43
|
+
/** Sets `data-aui-vscode-surface` for `@assistant-ui/vscode/theme.css`. */
|
|
44
|
+
surface?: WebviewSurface;
|
|
45
|
+
/** Id of the empty mount element; `null` omits it. */
|
|
46
|
+
rootId?: string | null;
|
|
47
|
+
/** `"module"` (default) or `"classic"` deferred scripts. */
|
|
48
|
+
scriptType?: "module" | "classic";
|
|
49
|
+
htmlAttributes?: Readonly<Record<string, string>>;
|
|
50
|
+
bodyAttributes?: Readonly<Record<string, string>>;
|
|
51
|
+
};
|
|
52
|
+
/** Returns a base64 nonce from 16 cryptographically random bytes. */
|
|
53
|
+
export declare function createCspNonce(): string;
|
|
54
|
+
/**
|
|
55
|
+
* Returns the Content-Security-Policy for a webview whose scripts carry
|
|
56
|
+
* `nonce` and whose other resources load through `asWebviewUri`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createWebviewCsp(webview: WebviewCspSource, options: WebviewCspOptions): string;
|
|
59
|
+
/**
|
|
60
|
+
* Renders the HTML document for a webview: a CSP meta tag, the nonce in
|
|
61
|
+
* `<meta property="csp-nonce">` for `getCspNonce()`, the stylesheets, a mount
|
|
62
|
+
* element, and the scripts, each carrying the nonce.
|
|
63
|
+
*/
|
|
64
|
+
export declare function renderWebviewHtml<U>(webview: WebviewResourceLike<U>, options: RenderWebviewHtmlOptions<U>): string;
|
|
65
|
+
//# sourceMappingURL=html.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../src/host/html.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,mBAAmB,CAAC,CAAC,IAAI,gBAAgB,GAAG;IACtD,YAAY,CAAC,GAAG,EAAE,CAAC,GAAG;QAAE,QAAQ,IAAI,MAAM,CAAA;KAAE,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAElD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE5D,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI,IAAI,CAAC,iBAAiB,EAAE,OAAO,CAAC,GAAG;IAC3E,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,SAAS,CAAC,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAClC,cAAc,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,cAAc,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD,CAAC;AAsDF,qEAAqE;AACrE,wBAAgB,cAAc,IAAI,MAAM,CASvC;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,gBAAgB,EACzB,OAAO,EAAE,iBAAiB,GACzB,MAAM,CA2CR;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAC,EAC/B,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,MAAM,CA4CR"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/host/html.ts
|
|
2
|
+
const NONCE_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
3
|
+
const ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;
|
|
4
|
+
const INVALID_SOURCE_PATTERN = /[\s;,'"]/;
|
|
5
|
+
const HTML_ESCAPES = {
|
|
6
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
"\"": """,
|
|
10
|
+
"'": "'"
|
|
11
|
+
};
|
|
12
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
|
|
13
|
+
const assertNonce = (nonce) => {
|
|
14
|
+
if (!NONCE_PATTERN.test(nonce)) throw new Error(`Invalid CSP nonce: ${JSON.stringify(nonce)}`);
|
|
15
|
+
};
|
|
16
|
+
const sources = (directive, values) => {
|
|
17
|
+
for (const value of values) if (value === "" || INVALID_SOURCE_PATTERN.test(value)) throw new Error(`Invalid ${directive} source: ${JSON.stringify(value)}. Pass one host or scheme source per entry.`);
|
|
18
|
+
return values;
|
|
19
|
+
};
|
|
20
|
+
const cspSourceList = (cspSource) => {
|
|
21
|
+
const list = cspSource.split(/\s+/).filter((value) => value !== "");
|
|
22
|
+
if (list.length === 0) throw new Error("Invalid cspSource: empty");
|
|
23
|
+
sources("cspSource", list.filter((value) => value !== "'self'"));
|
|
24
|
+
return list.join(" ");
|
|
25
|
+
};
|
|
26
|
+
const attributes = (values) => Object.entries(values).map(([name, value]) => {
|
|
27
|
+
if (!ATTRIBUTE_NAME_PATTERN.test(name)) throw new Error(`Invalid attribute name: ${JSON.stringify(name)}`);
|
|
28
|
+
return ` ${name}="${escapeHtml(value)}"`;
|
|
29
|
+
}).join("");
|
|
30
|
+
/** Returns a base64 nonce from 16 cryptographically random bytes. */
|
|
31
|
+
function createCspNonce() {
|
|
32
|
+
const crypto = globalThis.crypto;
|
|
33
|
+
if (typeof crypto?.getRandomValues !== "function") throw new Error("createCspNonce() needs globalThis.crypto.getRandomValues (Node.js 19 or later).");
|
|
34
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
35
|
+
return btoa(String.fromCharCode(...bytes));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns the Content-Security-Policy for a webview whose scripts carry
|
|
39
|
+
* `nonce` and whose other resources load through `asWebviewUri`.
|
|
40
|
+
*/
|
|
41
|
+
function createWebviewCsp(webview, options) {
|
|
42
|
+
const { nonce, csp = "strict" } = options;
|
|
43
|
+
assertNonce(nonce);
|
|
44
|
+
const cspSource = cspSourceList(webview.cspSource);
|
|
45
|
+
const frameSrc = sources("frame-src", options.frameSrc ?? []);
|
|
46
|
+
return [
|
|
47
|
+
["default-src", "'none'"],
|
|
48
|
+
[
|
|
49
|
+
"script-src",
|
|
50
|
+
`'nonce-${nonce}'`,
|
|
51
|
+
...sources("script-src", options.scriptSrc ?? []),
|
|
52
|
+
...options.wasmUnsafeEval ? ["'wasm-unsafe-eval'"] : []
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"style-src",
|
|
56
|
+
cspSource,
|
|
57
|
+
csp === "relaxed" ? "'unsafe-inline'" : `'nonce-${nonce}'`
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"img-src",
|
|
61
|
+
cspSource,
|
|
62
|
+
"blob:",
|
|
63
|
+
"data:",
|
|
64
|
+
...sources("img-src", options.imgSrc ?? [])
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"media-src",
|
|
68
|
+
cspSource,
|
|
69
|
+
"blob:",
|
|
70
|
+
"data:",
|
|
71
|
+
...sources("media-src", options.mediaSrc ?? [])
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
"font-src",
|
|
75
|
+
cspSource,
|
|
76
|
+
"data:"
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
"connect-src",
|
|
80
|
+
cspSource,
|
|
81
|
+
...sources("connect-src", options.connectSrc ?? [])
|
|
82
|
+
],
|
|
83
|
+
["frame-src", ...frameSrc.length > 0 ? frameSrc : ["'none'"]]
|
|
84
|
+
].map((directive) => directive.join(" ")).join("; ");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Renders the HTML document for a webview: a CSP meta tag, the nonce in
|
|
88
|
+
* `<meta property="csp-nonce">` for `getCspNonce()`, the stylesheets, a mount
|
|
89
|
+
* element, and the scripts, each carrying the nonce.
|
|
90
|
+
*/
|
|
91
|
+
function renderWebviewHtml(webview, options) {
|
|
92
|
+
const { scripts, styles = [], title = "", lang = "en", nonce = createCspNonce(), surface, rootId = "root", scriptType = "module", htmlAttributes = {}, bodyAttributes = {}, ...cspOptions } = options;
|
|
93
|
+
const csp = createWebviewCsp(webview, {
|
|
94
|
+
...cspOptions,
|
|
95
|
+
nonce
|
|
96
|
+
});
|
|
97
|
+
const url = (uri) => escapeHtml(webview.asWebviewUri(uri).toString());
|
|
98
|
+
const scriptAttributes = scriptType === "module" ? " type=\"module\"" : " defer";
|
|
99
|
+
return [
|
|
100
|
+
"<!doctype html>",
|
|
101
|
+
`<html${attributes({
|
|
102
|
+
lang,
|
|
103
|
+
...htmlAttributes
|
|
104
|
+
})}>`,
|
|
105
|
+
"<head>",
|
|
106
|
+
"<meta charset=\"utf-8\">",
|
|
107
|
+
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">`,
|
|
108
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
|
|
109
|
+
`<meta property="csp-nonce" nonce="${nonce}">`,
|
|
110
|
+
`<title>${escapeHtml(title)}</title>`,
|
|
111
|
+
...styles.map((uri) => `<link rel="stylesheet" href="${url(uri)}">`),
|
|
112
|
+
"</head>",
|
|
113
|
+
`<body${attributes({
|
|
114
|
+
...surface ? { "data-aui-vscode-surface": surface } : {},
|
|
115
|
+
...bodyAttributes
|
|
116
|
+
})}>`,
|
|
117
|
+
...rootId === null ? [] : [`<div id="${escapeHtml(rootId)}"></div>`],
|
|
118
|
+
...scripts.map((uri) => `<script${scriptAttributes} nonce="${nonce}" src="${url(uri)}"><\/script>`),
|
|
119
|
+
"</body>",
|
|
120
|
+
"</html>",
|
|
121
|
+
""
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
export { createCspNonce, createWebviewCsp, renderWebviewHtml };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=html.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.js","names":[],"sources":["../../src/host/html.ts"],"sourcesContent":["export type WebviewCspSource = {\n /** A source list, such as VS Code's `'self' https://*.vscode-cdn.net`. */\n readonly cspSource: string;\n};\n\nexport type WebviewResourceLike<U> = WebviewCspSource & {\n asWebviewUri(uri: U): { toString(): string };\n};\n\nexport type WebviewCspMode = \"strict\" | \"relaxed\";\n\nexport type WebviewCspOptions = {\n nonce: string;\n /**\n * `\"relaxed\"` swaps the style nonce for `'unsafe-inline'`, for libraries\n * that inject `<style>` tags without a nonce.\n */\n csp?: WebviewCspMode;\n /**\n * Extra `script-src` sources, one host or scheme per entry. Code-split\n * chunks need none: a module script's imports inherit its nonce.\n */\n scriptSrc?: readonly string[];\n connectSrc?: readonly string[];\n /**\n * Extra `img-src` sources, such as `\"https:\"`. Remote images are blocked by\n * default, because an image URL in model output can carry chat data away.\n */\n imgSrc?: readonly string[];\n /** Extra `media-src` sources, blocked by default like `imgSrc`. */\n mediaSrc?: readonly string[];\n frameSrc?: readonly string[];\n /** Adds `'wasm-unsafe-eval'` to `script-src` for WebAssembly modules. */\n wasmUnsafeEval?: boolean;\n};\n\nexport type WebviewSurface = \"sidebar\" | \"editor\" | \"panel\";\n\nexport type RenderWebviewHtmlOptions<U> = Omit<WebviewCspOptions, \"nonce\"> & {\n scripts: readonly U[];\n styles?: readonly U[];\n title?: string;\n lang?: string;\n /** Defaults to a fresh random nonce per render. */\n nonce?: string;\n /** Sets `data-aui-vscode-surface` for `@assistant-ui/vscode/theme.css`. */\n surface?: WebviewSurface;\n /** Id of the empty mount element; `null` omits it. */\n rootId?: string | null;\n /** `\"module\"` (default) or `\"classic\"` deferred scripts. */\n scriptType?: \"module\" | \"classic\";\n htmlAttributes?: Readonly<Record<string, string>>;\n bodyAttributes?: Readonly<Record<string, string>>;\n};\n\nconst NONCE_PATTERN = /^[A-Za-z0-9+/_-]+={0,2}$/;\nconst ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][-A-Za-z0-9_:.]*$/;\nconst INVALID_SOURCE_PATTERN = /[\\s;,'\"]/;\n\nconst HTML_ESCAPES: Record<string, string> = {\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n};\n\nconst escapeHtml = (value: string) =>\n value.replace(/[&<>\"']/g, (char) => HTML_ESCAPES[char] ?? char);\n\nconst assertNonce = (nonce: string) => {\n if (!NONCE_PATTERN.test(nonce)) {\n throw new Error(`Invalid CSP nonce: ${JSON.stringify(nonce)}`);\n }\n};\n\nconst sources = (directive: string, values: readonly string[]) => {\n for (const value of values) {\n if (value === \"\" || INVALID_SOURCE_PATTERN.test(value)) {\n throw new Error(\n `Invalid ${directive} source: ${JSON.stringify(value)}. Pass one host or scheme source per entry.`,\n );\n }\n }\n return values;\n};\n\nconst cspSourceList = (cspSource: string) => {\n const list = cspSource.split(/\\s+/).filter((value) => value !== \"\");\n if (list.length === 0) throw new Error(\"Invalid cspSource: empty\");\n sources(\n \"cspSource\",\n list.filter((value) => value !== \"'self'\"),\n );\n return list.join(\" \");\n};\n\nconst attributes = (values: Readonly<Record<string, string>>) =>\n Object.entries(values)\n .map(([name, value]) => {\n if (!ATTRIBUTE_NAME_PATTERN.test(name)) {\n throw new Error(`Invalid attribute name: ${JSON.stringify(name)}`);\n }\n return ` ${name}=\"${escapeHtml(value)}\"`;\n })\n .join(\"\");\n\n/** Returns a base64 nonce from 16 cryptographically random bytes. */\nexport function createCspNonce(): string {\n const crypto = globalThis.crypto;\n if (typeof crypto?.getRandomValues !== \"function\") {\n throw new Error(\n \"createCspNonce() needs globalThis.crypto.getRandomValues (Node.js 19 or later).\",\n );\n }\n const bytes = crypto.getRandomValues(new Uint8Array(16));\n return btoa(String.fromCharCode(...bytes));\n}\n\n/**\n * Returns the Content-Security-Policy for a webview whose scripts carry\n * `nonce` and whose other resources load through `asWebviewUri`.\n */\nexport function createWebviewCsp(\n webview: WebviewCspSource,\n options: WebviewCspOptions,\n): string {\n const { nonce, csp = \"strict\" } = options;\n assertNonce(nonce);\n const cspSource = cspSourceList(webview.cspSource);\n const frameSrc = sources(\"frame-src\", options.frameSrc ?? []);\n\n const directives: [string, ...string[]][] = [\n [\"default-src\", \"'none'\"],\n [\n \"script-src\",\n `'nonce-${nonce}'`,\n ...sources(\"script-src\", options.scriptSrc ?? []),\n ...(options.wasmUnsafeEval ? [\"'wasm-unsafe-eval'\"] : []),\n ],\n [\n \"style-src\",\n cspSource,\n csp === \"relaxed\" ? \"'unsafe-inline'\" : `'nonce-${nonce}'`,\n ],\n [\n \"img-src\",\n cspSource,\n \"blob:\",\n \"data:\",\n ...sources(\"img-src\", options.imgSrc ?? []),\n ],\n [\n \"media-src\",\n cspSource,\n \"blob:\",\n \"data:\",\n ...sources(\"media-src\", options.mediaSrc ?? []),\n ],\n [\"font-src\", cspSource, \"data:\"],\n [\n \"connect-src\",\n cspSource,\n ...sources(\"connect-src\", options.connectSrc ?? []),\n ],\n [\"frame-src\", ...(frameSrc.length > 0 ? frameSrc : [\"'none'\"])],\n ];\n\n return directives.map((directive) => directive.join(\" \")).join(\"; \");\n}\n\n/**\n * Renders the HTML document for a webview: a CSP meta tag, the nonce in\n * `<meta property=\"csp-nonce\">` for `getCspNonce()`, the stylesheets, a mount\n * element, and the scripts, each carrying the nonce.\n */\nexport function renderWebviewHtml<U>(\n webview: WebviewResourceLike<U>,\n options: RenderWebviewHtmlOptions<U>,\n): string {\n const {\n scripts,\n styles = [],\n title = \"\",\n lang = \"en\",\n nonce = createCspNonce(),\n surface,\n rootId = \"root\",\n scriptType = \"module\",\n htmlAttributes = {},\n bodyAttributes = {},\n ...cspOptions\n } = options;\n\n const csp = createWebviewCsp(webview, { ...cspOptions, nonce });\n const url = (uri: U) => escapeHtml(webview.asWebviewUri(uri).toString());\n const scriptAttributes =\n scriptType === \"module\" ? ' type=\"module\"' : \" defer\";\n\n return [\n \"<!doctype html>\",\n `<html${attributes({ lang, ...htmlAttributes })}>`,\n \"<head>\",\n '<meta charset=\"utf-8\">',\n `<meta http-equiv=\"Content-Security-Policy\" content=\"${escapeHtml(csp)}\">`,\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">',\n `<meta property=\"csp-nonce\" nonce=\"${nonce}\">`,\n `<title>${escapeHtml(title)}</title>`,\n ...styles.map((uri) => `<link rel=\"stylesheet\" href=\"${url(uri)}\">`),\n \"</head>\",\n `<body${attributes({\n ...(surface ? { \"data-aui-vscode-surface\": surface } : {}),\n ...bodyAttributes,\n })}>`,\n ...(rootId === null ? [] : [`<div id=\"${escapeHtml(rootId)}\"></div>`]),\n ...scripts.map(\n (uri) =>\n `<script${scriptAttributes} nonce=\"${nonce}\" src=\"${url(uri)}\"></script>`,\n ),\n \"</body>\",\n \"</html>\",\n \"\",\n ].join(\"\\n\");\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type RouteHandler = (request: Request) => Response | Promise<Response>;
|
|
2
|
+
export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
|
|
3
|
+
export type RouteHandlers = {
|
|
4
|
+
[M in HttpMethod]?: RouteHandler;
|
|
5
|
+
};
|
|
6
|
+
export type WebviewRoutes = Record<string, RouteHandlers>;
|
|
7
|
+
export type WebviewLike = {
|
|
8
|
+
postMessage(message: any): PromiseLike<boolean>;
|
|
9
|
+
onDidReceiveMessage(listener: (message: any) => unknown): {
|
|
10
|
+
dispose(): unknown;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export type ServeWebviewRoutesOptions = {
|
|
14
|
+
/** Milliseconds to coalesce response chunks before posting them. */
|
|
15
|
+
flushInterval?: number;
|
|
16
|
+
/** Buffered byte count that posts a chunk without waiting. */
|
|
17
|
+
flushSize?: number;
|
|
18
|
+
onError?: (error: unknown) => void;
|
|
19
|
+
};
|
|
20
|
+
export type Disposable = {
|
|
21
|
+
dispose(): void;
|
|
22
|
+
};
|
|
23
|
+
export declare function serveRoutes(webview: WebviewLike, routes: WebviewRoutes, { flushInterval, flushSize, onError, }?: ServeWebviewRoutesOptions): Disposable;
|
|
24
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/host/router.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE9E,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,MAAM,GACN,MAAM,GACN,KAAK,GACL,OAAO,GACP,QAAQ,GACR,SAAS,CAAC;AAEd,MAAM,MAAM,aAAa,GAAG;KAAG,CAAC,IAAI,UAAU,CAAC,CAAC,EAAE,YAAY;CAAE,CAAC;AAEjE,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAE1D,MAAM,MAAM,WAAW,GAAG;IACxB,WAAW,CAAC,OAAO,EAAE,GAAG,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC;IAChD,mBAAmB,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,GAAG;QACxD,OAAO,IAAI,OAAO,CAAC;KACpB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IAAE,OAAO,IAAI,IAAI,CAAA;CAAE,CAAC;AAiB7C,wBAAgB,WAAW,CACzB,OAAO,EAAE,WAAW,EACpB,MAAM,EAAE,aAAa,EACrB,EACE,aAAkB,EAClB,SAAqB,EACrB,OAAyC,GAC1C,GAAE,yBAA8B,GAChC,UAAU,CA4LZ"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { VSCODE_BRIDGE_CHANNEL, VSCODE_VIRTUAL_ORIGIN, isWebviewToHostMessage } from "../protocol.js";
|
|
2
|
+
import { isForeignHttpUrl } from "../route-url.js";
|
|
3
|
+
// src/host/router.ts
|
|
4
|
+
const BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
|
|
5
|
+
const concat = (parts, size) => {
|
|
6
|
+
const out = new Uint8Array(size);
|
|
7
|
+
let offset = 0;
|
|
8
|
+
for (const part of parts) {
|
|
9
|
+
out.set(part, offset);
|
|
10
|
+
offset += part.byteLength;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
};
|
|
14
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
15
|
+
function serveRoutes(webview, routes, { flushInterval = 16, flushSize = 65536, onError = (error) => console.error(error) } = {}) {
|
|
16
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
17
|
+
const post = (message, controller) => {
|
|
18
|
+
Promise.resolve(webview.postMessage(message)).then((delivered) => {
|
|
19
|
+
if (!delivered) controller.abort();
|
|
20
|
+
}, () => controller.abort());
|
|
21
|
+
};
|
|
22
|
+
const dispatch = async (request, pathname) => {
|
|
23
|
+
const route = Object.hasOwn(routes, pathname) ? routes[pathname] : null;
|
|
24
|
+
if (!route) return new Response("Not Found", { status: 404 });
|
|
25
|
+
const handler = Object.hasOwn(route, request.method) ? route[request.method] : null;
|
|
26
|
+
if (!handler) return new Response("Method Not Allowed", {
|
|
27
|
+
status: 405,
|
|
28
|
+
headers: { Allow: Object.keys(route).join(", ") }
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
return await handler(request);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (request.signal.aborted) throw error;
|
|
34
|
+
onError(error);
|
|
35
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const pump = async (id, body, controller) => {
|
|
39
|
+
const { signal } = controller;
|
|
40
|
+
const reader = body.getReader();
|
|
41
|
+
const onAbort = () => {
|
|
42
|
+
reader.cancel(signal.reason).catch(() => void 0);
|
|
43
|
+
};
|
|
44
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
45
|
+
let buffered = [];
|
|
46
|
+
let size = 0;
|
|
47
|
+
let timer;
|
|
48
|
+
const flush = () => {
|
|
49
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
50
|
+
timer = void 0;
|
|
51
|
+
if (size === 0 || signal.aborted) return;
|
|
52
|
+
const chunk = concat(buffered, size);
|
|
53
|
+
buffered = [];
|
|
54
|
+
size = 0;
|
|
55
|
+
post({
|
|
56
|
+
channel: VSCODE_BRIDGE_CHANNEL,
|
|
57
|
+
kind: "fetch:chunk",
|
|
58
|
+
id,
|
|
59
|
+
chunk
|
|
60
|
+
}, controller);
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done || signal.aborted) break;
|
|
66
|
+
if (value.byteLength === 0) continue;
|
|
67
|
+
buffered.push(value);
|
|
68
|
+
size += value.byteLength;
|
|
69
|
+
if (size >= flushSize || flushInterval <= 0) flush();
|
|
70
|
+
else timer ??= setTimeout(flush, flushInterval);
|
|
71
|
+
}
|
|
72
|
+
flush();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
flush();
|
|
75
|
+
throw error;
|
|
76
|
+
} finally {
|
|
77
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
78
|
+
signal.removeEventListener("abort", onAbort);
|
|
79
|
+
reader.releaseLock();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const handle = async (message) => {
|
|
83
|
+
const { id } = message;
|
|
84
|
+
if (inflight.has(id)) {
|
|
85
|
+
onError(/* @__PURE__ */ new Error(`Ignored a request reusing the in-flight id ${id}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
inflight.set(id, controller);
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(message.url);
|
|
92
|
+
if (isForeignHttpUrl(url)) throw new TypeError(`Refused a request for ${url.origin}`);
|
|
93
|
+
const requestUrl = new URL(VSCODE_VIRTUAL_ORIGIN);
|
|
94
|
+
requestUrl.pathname = url.pathname;
|
|
95
|
+
requestUrl.search = url.search;
|
|
96
|
+
const request = new Request(requestUrl, {
|
|
97
|
+
method: message.method,
|
|
98
|
+
headers: message.headers,
|
|
99
|
+
body: BODYLESS_METHODS.has(message.method.toUpperCase()) ? null : message.body,
|
|
100
|
+
signal: controller.signal
|
|
101
|
+
});
|
|
102
|
+
const response = await dispatch(request, url.pathname);
|
|
103
|
+
if (controller.signal.aborted) {
|
|
104
|
+
response.body?.cancel().catch(() => void 0);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
post({
|
|
108
|
+
channel: VSCODE_BRIDGE_CHANNEL,
|
|
109
|
+
kind: "fetch:head",
|
|
110
|
+
id,
|
|
111
|
+
status: response.status,
|
|
112
|
+
statusText: response.statusText,
|
|
113
|
+
headers: [...response.headers]
|
|
114
|
+
}, controller);
|
|
115
|
+
if (request.method === "HEAD") response.body?.cancel().catch(() => void 0);
|
|
116
|
+
else if (response.body) await pump(id, response.body, controller);
|
|
117
|
+
if (!controller.signal.aborted) post({
|
|
118
|
+
channel: VSCODE_BRIDGE_CHANNEL,
|
|
119
|
+
kind: "fetch:end",
|
|
120
|
+
id
|
|
121
|
+
}, controller);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (controller.signal.aborted) return;
|
|
124
|
+
onError(error);
|
|
125
|
+
post({
|
|
126
|
+
channel: VSCODE_BRIDGE_CHANNEL,
|
|
127
|
+
kind: "fetch:error",
|
|
128
|
+
id,
|
|
129
|
+
message: errorMessage(error)
|
|
130
|
+
}, controller);
|
|
131
|
+
} finally {
|
|
132
|
+
if (inflight.get(id) === controller) inflight.delete(id);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const subscription = webview.onDidReceiveMessage((message) => {
|
|
136
|
+
if (!isWebviewToHostMessage(message)) return;
|
|
137
|
+
if (message.kind === "fetch:request") handle(message);
|
|
138
|
+
else if (message.kind === "fetch:reset") {
|
|
139
|
+
const controllers = [...inflight.values()];
|
|
140
|
+
inflight.clear();
|
|
141
|
+
for (const controller of controllers) controller.abort();
|
|
142
|
+
} else if (message.kind === "fetch:abort") {
|
|
143
|
+
const controller = inflight.get(message.id);
|
|
144
|
+
if (controller) {
|
|
145
|
+
inflight.delete(message.id);
|
|
146
|
+
controller.abort();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return { dispose: () => {
|
|
151
|
+
subscription.dispose();
|
|
152
|
+
for (const [id, controller] of inflight) {
|
|
153
|
+
post({
|
|
154
|
+
channel: VSCODE_BRIDGE_CHANNEL,
|
|
155
|
+
kind: "fetch:error",
|
|
156
|
+
id,
|
|
157
|
+
message: "Webview routes disposed"
|
|
158
|
+
}, controller);
|
|
159
|
+
controller.abort();
|
|
160
|
+
}
|
|
161
|
+
inflight.clear();
|
|
162
|
+
} };
|
|
163
|
+
}
|
|
164
|
+
export { serveRoutes };
|
|
165
|
+
|
|
166
|
+
//# sourceMappingURL=router.js.map
|