wgsl-analyzer-web 0.12.0-next.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/host.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Hosts the emscripten module: boots it, seeds the workspace, and carries the
3
+ * message stream.
4
+ */
5
+
6
+ import type { ModuleFactory } from "./emscripten.js";
7
+ import { seedWorkspace, writeFile } from "./fs.js";
8
+ import type { WorkspaceFiles } from "./protocol.js";
9
+
10
+ export interface HostOptions {
11
+ /** Absolute path of the workspace inside the in-memory filesystem. */
12
+ readonly root: string;
13
+ /** Files to seed, keyed by path relative to {@link HostOptions.root}. */
14
+ readonly files: WorkspaceFiles;
15
+ /** argv for `main()`. Empty selects the default `lsp-server` subcommand. */
16
+ readonly args: readonly string[];
17
+ /** Receives each parsed JSON-RPC message from the server. */
18
+ readonly onMessage: (message: unknown) => void;
19
+ /** Receives the server's stderr, line by line. */
20
+ readonly onStderr: (line: string) => void;
21
+ /** Called if `main()` returns. */
22
+ readonly onExit: (code: number) => void;
23
+ }
24
+
25
+ /** A running server. Once it has exited, every method does nothing. */
26
+ export interface Host {
27
+ /** Sends one JSON-RPC message to the server. */
28
+ send(message: unknown): void;
29
+ /** Creates or replaces a workspace file, given its path relative to the root. */
30
+ writeFile(relativePath: string, contents: string | Uint8Array): void;
31
+ /** Removes a workspace file, if present. */
32
+ deleteFile(relativePath: string): void;
33
+ }
34
+
35
+ /** Boots the server and resolves once `main()` has been started. */
36
+ export async function startHost(factory: ModuleFactory, options: HostOptions): Promise<Host> {
37
+ const { root } = options;
38
+ const resolve = (relativePath: string): string => `${root}/${relativePath.replace(/^\/+/, "")}`;
39
+
40
+ // Messages for the server, which pulls them one at a time. It never closes.
41
+ const inbox = new TransformStream<string, string>();
42
+ const reader = inbox.readable.getReader();
43
+ const writer = inbox.writable.getWriter();
44
+
45
+ // With `-sEXIT_RUNTIME`, calling into the module after `main()` returns is an
46
+ // error, and the page can still send messages after that.
47
+ let exited = false;
48
+ const module = await factory({
49
+ noInitialRun: true,
50
+ printErr: options.onStderr,
51
+ onExit: (code) => {
52
+ exited = true;
53
+ // Messages are delivered from microtasks too, so those sent before exiting come first.
54
+ queueMicrotask(() => options.onExit(code));
55
+ },
56
+ lspNextMessage: () => reader.read().then(({ value }) => value as string),
57
+ lspOnMessage: (body) => options.onMessage(JSON.parse(body)),
58
+ });
59
+
60
+ // Needs to run after WasmFS was iniialized.
61
+ seedWorkspace(module.FS, root, options.files);
62
+ module.FS.chdir(root);
63
+ // WasmFS's `chdir` does not go through `FS.handleError`, so a failure is
64
+ // otherwise silent.
65
+ if (module.FS.cwd() !== root) throw new Error(`cannot change directory to ${root}`);
66
+
67
+ // Returns immediately: -sPROXY_TO_PTHREAD runs main() on a pthread.
68
+ module.callMain(options.args);
69
+
70
+ return {
71
+ send: (message) => {
72
+ if (!exited) void writer.write(JSON.stringify(message));
73
+ },
74
+ writeFile: (relativePath, contents) => {
75
+ if (!exited) writeFile(module.FS, resolve(relativePath), contents);
76
+ },
77
+ deleteFile: (relativePath) => {
78
+ const path = resolve(relativePath);
79
+ if (!exited && module.FS.analyzePath(path).exists) module.FS.unlink(path);
80
+ },
81
+ };
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * wgsl-analyzer as a language server in a Web Worker.
3
+ *
4
+ * The server is the `wgsl-analyzer` binary compiled to
5
+ * `wasm32-unknown-emscripten`, running its ordinary `main_loop` over a transport.
6
+ * This module hosts it and exposes the message stream.
7
+ *
8
+ * The page must be cross-origin isolated. The build uses shared memory, so
9
+ * without `Cross-Origin-Opener-Policy: same-origin` and
10
+ * `Cross-Origin-Embedder-Policy: require-corp` nothing will start.
11
+ */
12
+
13
+ import type { HostMessage, WorkerMessage, WorkspaceFiles } from "./protocol.js";
14
+
15
+ export type { WorkspaceFiles } from "./protocol.js";
16
+
17
+ export interface StartOptions {
18
+ /**
19
+ * Directory serving `worker.js`, `wgsl_analyzer.js` and `wgsl_analyzer.wasm`.
20
+ * They must sit side by side. Defaults to `"/wgsl-analyzer/"`.
21
+ */
22
+ readonly baseUrl?: string | URL;
23
+ /** Absolute path of the workspace in the in-memory filesystem. Defaults to `"/workspace"`. */
24
+ readonly root?: string;
25
+ /** Files to seed, keyed by path relative to {@link StartOptions.root}. */
26
+ readonly files: WorkspaceFiles;
27
+ /** argv for `main()`. Empty selects the default `lsp-server` subcommand. */
28
+ readonly args?: readonly string[];
29
+ /** Receives the server's stderr, line by line. */
30
+ readonly onStderr?: (line: string) => void;
31
+ /** Called if `main()` returns. */
32
+ readonly onExit?: (code: number) => void;
33
+ }
34
+
35
+ export interface Disposable {
36
+ dispose(): void;
37
+ }
38
+
39
+ /** A running wgsl-analyzer language server. */
40
+ export class WgslAnalyzerServer {
41
+ readonly #worker: Worker;
42
+ readonly #listeners = new Set<(message: unknown) => void>();
43
+ readonly #root: string;
44
+ #disposed = false;
45
+
46
+ private constructor(worker: Worker, root: string) {
47
+ this.#worker = worker;
48
+ this.#root = root;
49
+ }
50
+
51
+ /** The workspace root as a URI, suitable for `initialize`. */
52
+ get rootUri(): string {
53
+ return `file://${this.#root}`;
54
+ }
55
+
56
+ /** Builds the URI of a workspace file, given its path relative to the root. */
57
+ uriOf(relativePath: string): string {
58
+ return `file://${this.#root}/${relativePath.replace(/^\/+/, "")}`;
59
+ }
60
+
61
+ /** Boots the server and resolves once it is ready to accept messages. */
62
+ static async start(options: StartOptions): Promise<WgslAnalyzerServer> {
63
+ const base = new URL(String(options.baseUrl ?? "/wgsl-analyzer/"), globalThis.location.href);
64
+ const root = options.root ?? "/workspace";
65
+
66
+ const worker = new Worker(new URL("worker.js", base), { type: "module" });
67
+ const server = new WgslAnalyzerServer(worker, root);
68
+
69
+ await new Promise<void>((resolve, reject) => {
70
+ const onFirstMessage = (event: MessageEvent<WorkerMessage>): void => {
71
+ const message = event.data;
72
+ if (message.type === "ready") {
73
+ worker.removeEventListener("message", onFirstMessage);
74
+ resolve();
75
+ } else if (message.type === "error") {
76
+ worker.removeEventListener("message", onFirstMessage);
77
+ reject(new Error(`${message.message}\n${message.stack ?? ""}`));
78
+ }
79
+ };
80
+ worker.addEventListener("message", onFirstMessage);
81
+ worker.addEventListener(
82
+ "error",
83
+ (event) => reject(new Error(`worker failed to load: ${event.message}`)),
84
+ { once: true },
85
+ );
86
+
87
+ const boot: HostMessage = {
88
+ type: "boot",
89
+ root,
90
+ files: options.files,
91
+ args: options.args ?? [],
92
+ };
93
+ worker.postMessage(boot);
94
+ });
95
+
96
+ worker.addEventListener("message", (event: MessageEvent<WorkerMessage>) => {
97
+ const message = event.data;
98
+ switch (message.type) {
99
+ case "lsp":
100
+ for (const listener of server.#listeners) listener(message.message);
101
+ break;
102
+ case "stderr":
103
+ options.onStderr?.(message.line);
104
+ break;
105
+ case "exit":
106
+ options.onExit?.(message.code);
107
+ break;
108
+ case "error":
109
+ options.onStderr?.(`[worker] ${message.message}`);
110
+ break;
111
+ default:
112
+ break;
113
+ }
114
+ });
115
+
116
+ return server;
117
+ }
118
+
119
+ /** Sends one JSON-RPC message to the server. */
120
+ sendMessage(message: unknown): void {
121
+ if (this.#disposed) throw new Error("server has been disposed");
122
+ const envelope: HostMessage = { type: "lsp", message };
123
+ this.#worker.postMessage(envelope);
124
+ }
125
+
126
+ /** Subscribes to messages coming back from the server. */
127
+ onMessage(listener: (message: unknown) => void): Disposable {
128
+ this.#listeners.add(listener);
129
+ return {
130
+ dispose: () => {
131
+ this.#listeners.delete(listener);
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Creates or replaces a file in the in-memory filesystem.
138
+ *
139
+ * The server does not watch the filesystem, so follow this with a
140
+ * `workspace/didChangeWatchedFiles` notification for the file. Edits to an
141
+ * open document go through `textDocument/didChange` instead.
142
+ */
143
+ writeFile(relativePath: string, contents: string | Uint8Array): void {
144
+ const message: HostMessage = { type: "writeFile", path: relativePath, contents };
145
+ this.#worker.postMessage(message);
146
+ }
147
+
148
+ /** Removes a file from the in-memory filesystem. See {@link WgslAnalyzerServer.writeFile}. */
149
+ deleteFile(relativePath: string): void {
150
+ const message: HostMessage = { type: "deleteFile", path: relativePath };
151
+ this.#worker.postMessage(message);
152
+ }
153
+
154
+ /** Stops the server and tears down the worker. */
155
+ dispose(): void {
156
+ if (this.#disposed) return;
157
+ this.#disposed = true;
158
+ this.#listeners.clear();
159
+
160
+ // `exit` ends the server's main loop, if the server reads it before the
161
+ // worker is terminated on the next macrotask.
162
+ const exit: HostMessage = { type: "lsp", message: { jsonrpc: "2.0", method: "exit" } };
163
+ this.#worker.postMessage(exit);
164
+ setTimeout(() => this.#worker.terminate(), 0);
165
+ }
166
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Control messages exchanged between the page and the worker hosting the
3
+ * emscripten module.
4
+ */
5
+
6
+ /** Contents of a workspace, keyed by path relative to its root in the in-memory filesystem. */
7
+ export type WorkspaceFiles = Record<string, string | Uint8Array>;
8
+
9
+ /** Page to worker. */
10
+ export type HostMessage =
11
+ | {
12
+ readonly type: "boot";
13
+ readonly root: string;
14
+ readonly files: WorkspaceFiles;
15
+ readonly args: readonly string[];
16
+ }
17
+ | { readonly type: "lsp"; readonly message: unknown }
18
+ | { readonly type: "writeFile"; readonly path: string; readonly contents: string | Uint8Array }
19
+ | { readonly type: "deleteFile"; readonly path: string };
20
+
21
+ /** Worker to page. */
22
+ export type WorkerMessage =
23
+ | { readonly type: "ready" }
24
+ | { readonly type: "lsp"; readonly message: unknown }
25
+ | { readonly type: "stderr"; readonly line: string }
26
+ | { readonly type: "exit"; readonly code: number }
27
+ | { readonly type: "error"; readonly message: string; readonly stack?: string | undefined };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Stands in for the emscripten glue.
3
+ *
4
+ * The real `wgsl_analyzer.js` is produced by `cargo xtask dist-web` and only
5
+ * exists in `dist/`, next to the bundled worker that imports it at runtime. This
6
+ * declaration lets the worker typecheck without it being present in `src/`.
7
+ */
8
+
9
+ import type { ModuleFactory } from "./emscripten.js";
10
+
11
+ declare const createWgslAnalyzer: ModuleFactory;
12
+ export default createWgslAnalyzer;
package/src/worker.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Worker entry point: relays between the page and the {@link Host}.
3
+ *
4
+ * This file is bundled to `dist/worker.js` and must be served next to
5
+ * `wgsl_analyzer.js`. The dynamic import below resolves relative to it, and the
6
+ * glue spawns its pthread pool with
7
+ * `new Worker(new URL("wgsl_analyzer.js", import.meta.url))`.
8
+ */
9
+
10
+ import { type Host, startHost } from "./host.js";
11
+ import type { HostMessage, WorkerMessage } from "./protocol.js";
12
+
13
+ const post = (message: WorkerMessage): void => {
14
+ self.postMessage(message);
15
+ };
16
+
17
+ let host: Host | null = null;
18
+
19
+ self.onmessage = (event: MessageEvent<HostMessage>): void => {
20
+ const message = event.data;
21
+ switch (message.type) {
22
+ case "boot":
23
+ boot(message).catch((error: unknown) => {
24
+ post({
25
+ type: "error",
26
+ message: String(error),
27
+ stack: error instanceof Error ? error.stack : undefined,
28
+ });
29
+ });
30
+ break;
31
+ case "lsp":
32
+ host?.send(message.message);
33
+ break;
34
+ case "writeFile":
35
+ host?.writeFile(message.path, message.contents);
36
+ break;
37
+ case "deleteFile":
38
+ host?.deleteFile(message.path);
39
+ break;
40
+ default:
41
+ break;
42
+ }
43
+ };
44
+
45
+ async function boot(message: Extract<HostMessage, { type: "boot" }>): Promise<void> {
46
+ // Left external by the bundler so it resolves next to dist/worker.js at
47
+ // runtime rather than being inlined.
48
+ const { default: createWgslAnalyzer } = await import("./wgsl_analyzer.js");
49
+
50
+ host = await startHost(createWgslAnalyzer, {
51
+ root: message.root,
52
+ files: message.files,
53
+ args: message.args,
54
+ onMessage: (lsp) => post({ type: "lsp", message: lsp }),
55
+ onStderr: (line) => post({ type: "stderr", line }),
56
+ onExit: (code) => post({ type: "exit", code }),
57
+ });
58
+
59
+ post({ type: "ready" });
60
+ }