diff --git a/build.py b/build.py index 619658e014..caa8738e07 100755 --- a/build.py +++ b/build.py @@ -135,7 +135,7 @@ npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm" build_type = "debug" if args.debug else "release" -wasm_targets = ["web", "nodejs"] if not args.web_only else ["web"] +wasm_targets = ["web"] if not args.web_only else ["web"] run_tests = args.test root_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/package-lock.json b/package-lock.json index e61c868d7e..3ed194e7d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6424,6 +6424,12 @@ "node": ">= 14" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6684,6 +6690,9 @@ "name": "qsharp-lang", "version": "0.0.0", "license": "MIT", + "dependencies": { + "web-worker": "^1.5.0" + }, "engines": { "node": ">=16.17.0" } diff --git a/source/npm/qsharp/README.md b/source/npm/qsharp/README.md index 814d2847b0..7939fbb65d 100644 --- a/source/npm/qsharp/README.md +++ b/source/npm/qsharp/README.md @@ -11,28 +11,11 @@ to it when calling the `loadWasmModule` method so it may be located and loaded. ## Node and browser support -wasm-bindgen generates different files for the browser and Node.js environments. The wasm is slightly -different, and the loader code is quite different. This can be seen in `./lib/web/qsc_wasm.cjs` -and `./lib/nodejs/qsc_wasm.js` files respectively. Specifically, the web environment loads the wasm -file using async web APIs such as `fetch` with a URI, and Node.js uses `require` to load the `fs` module -and calls to `readFileSync`. Once the wasm module is loaded however, the exported APIs are used -in a similar manner. - -To support using this npm package from both environments, the package uses "conditional exports" - to expose one -entry point for Node.js, and another for browsers. The distinct entry points uses their respective -loader to load the wasm module for the platform, and then expose functionality that uses the -loaded module via common code. - -When bundling for the web, bundlers such as esbuild will automatically use the default entry point, -whereas when loaded as a Node.js module, it will use the "node" entry point. - -Note that TypeScript seems to add the ['import', 'types', 'node'] conditions by default when -searching the Node.js `exports`, and so will always find the 'node' export before the 'default' -export. To resolve this, a 'browser' condition was added (which is same as 'default' but earlier -than 'node') and the tsconfig compiler option `"customConditions": ["browser"]` should be added -(requires TypeScript 5.0 or later). esbuild also adds the 'browser' condition when bundling for -the browser (see ). +This package is platform-agnostic, using a single entry point (`main.ts`) for both browser +and Node.js environments. The wasm module and JavaScript glue code can be found in `./lib/web/`. +Consumers must bundle the package for their target platform so that external +dependencies (such as the `web-worker` package) are resolved correctly for the +runtime environment. ## Design diff --git a/source/npm/qsharp/generate_docs.js b/source/npm/qsharp/generate_docs.js index c01fb9074a..2f00bb50c8 100644 --- a/source/npm/qsharp/generate_docs.js +++ b/source/npm/qsharp/generate_docs.js @@ -3,11 +3,11 @@ // @ts-check -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { generate_docs } from "./lib/nodejs/qsc_wasm.cjs"; +import initWasm, { generate_docs } from "./lib/web/qsc_wasm.js"; const scriptDirPath = dirname(fileURLToPath(import.meta.url)); const docsDirPath = join(scriptDirPath, "docs"); @@ -16,6 +16,11 @@ if (!existsSync(docsDirPath)) { mkdirSync(docsDirPath); } +// Initialize wasm before calling any exported functions +const wasmPath = join(scriptDirPath, "lib", "web", "qsc_wasm_bg.wasm"); +const wasmBytes = readFileSync(wasmPath); +await initWasm({ module_or_path: wasmBytes }); + // 'filename' will be of the format 'namespace/api.md' (except for 'toc.yaml') // 'metadata' will be the metadata that will appear at the top of the file // 'contents' will contain the non-metadata markdown expected diff --git a/source/npm/qsharp/package.json b/source/npm/qsharp/package.json index c3bb4400d2..66ddb79ff8 100644 --- a/source/npm/qsharp/package.json +++ b/source/npm/qsharp/package.json @@ -12,14 +12,10 @@ "directory": "npm" }, "exports": { - ".": { - "browser": "./dist/browser.js", - "node": "./dist/main.js", - "default": "./dist/browser.js" - }, - "./compiler-worker": "./dist/compiler/worker-browser.js", - "./language-service-worker": "./dist/language-service/worker-browser.js", - "./debug-service-worker": "./dist/debug-service/worker-browser.js", + ".": "./dist/main.js", + "./compiler-worker": "./dist/compiler/worker.js", + "./language-service-worker": "./dist/language-service/worker.js", + "./debug-service-worker": "./dist/debug-service/worker.js", "./katas": "./dist/katas.js", "./katas-md": "./dist/katas-md.js", "./state-viz": "./ux/circuit-vis/state-viz/worker/index.ts", @@ -44,5 +40,8 @@ "docs", "lib", "ux" - ] + ], + "dependencies": { + "web-worker": "^1.5.0" + } } diff --git a/source/npm/qsharp/src/browser.ts b/source/npm/qsharp/src/browser.ts deleted file mode 100644 index 83b7a117d1..0000000000 --- a/source/npm/qsharp/src/browser.ts +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// This module is the entry point for browser environments. For Node.js environment, -// the "./main.js" module is the entry point. - -import * as wasm from "../lib/web/qsc_wasm.js"; -import initWasm, { - IProjectHost, - ProjectType, - TargetProfile, -} from "../lib/web/qsc_wasm.js"; -import { - Compiler, - ICompiler, - ICompilerWorker, - compilerProtocol, -} from "./compiler/compiler.js"; -import { - IDebugService, - IDebugServiceWorker, - QSharpDebugService, - debugServiceProtocol, -} from "./debug-service/debug-service.js"; -import { callAndTransformExceptions } from "./diagnostics.js"; -import { - ILanguageService, - ILanguageServiceWorker, - QSharpLanguageService, - languageServiceProtocol, - qsharpGithubUriScheme, - qsharpLibraryUriScheme, -} from "./language-service/language-service.js"; -import { LogLevel, log } from "./log.js"; -import { ProjectLoader } from "./project.js"; -import { createProxy } from "./workers/browser.js"; - -export { qsharpGithubUriScheme, qsharpLibraryUriScheme }; - -// Create once. A module is stateless and can be efficiently passed to WebWorkers. -let wasmModule: WebAssembly.Module | null = null; -let wasmModulePromise: Promise | null = null; - -// Used to track if an instance is already instantiated -let wasmInstancePromise: Promise | null = null; - -async function wasmLoader(uriOrBuffer: string | ArrayBuffer) { - if (typeof uriOrBuffer === "string") { - log.info("Fetching wasm module from %s", uriOrBuffer); - performance.mark("fetch-wasm-start"); - const wasmRequst = await fetch(uriOrBuffer); - const wasmBuffer = await wasmRequst.arrayBuffer(); - const fetchTiming = performance.measure("fetch-wasm", "fetch-wasm-start"); - log.logTelemetry({ - id: "fetch-wasm", - data: { - duration: fetchTiming.duration, - uri: uriOrBuffer, - }, - }); - - wasmModule = await WebAssembly.compile(wasmBuffer); - } else { - log.info("Compiling wasm module from provided buffer"); - wasmModule = await WebAssembly.compile(uriOrBuffer); - } -} - -export function loadWasmModule( - uriOrBuffer: string | ArrayBuffer, -): Promise { - // Only initiate if not already in flight, to avoid race conditions - if (!wasmModulePromise) { - wasmModulePromise = wasmLoader(uriOrBuffer); - } - return wasmModulePromise; -} - -async function instantiateWasm() { - // Ensure loading the module has been initiated, and wait for it. - if (!wasmModulePromise) throw "Wasm module must be loaded first"; - await wasmModulePromise; - if (!wasmModule) throw "Wasm module failed to load"; - - if (wasmInstancePromise) { - // Either in flight or already complete. The prior request will do the init, - // so just wait on that. - await wasmInstancePromise; - return; - } - - // Set the promise to signal this is in flight, then wait on the result. - wasmInstancePromise = initWasm(wasmModule); - await wasmInstancePromise; - - // Once ready, set up logging and telemetry as soon as possible after instantiating - wasm.initLogging(log.logWithLevel, log.getLogLevel()); - log.onLevelChanged = (level) => wasm.setLogLevel(level); -} - -export async function getLibrarySourceContent( - path: string, -): Promise { - await instantiateWasm(); - return wasm.get_library_source_content(path); -} - -export async function getDebugService(): Promise { - await instantiateWasm(); - return new QSharpDebugService(wasm); -} - -export async function getProjectLoader( - host: IProjectHost, -): Promise { - await instantiateWasm(); - return new ProjectLoader(wasm, host); -} - -// Create the debugger inside a WebWorker and proxy requests. -// If the Worker was already created via other means and is ready to receive -// messages, then the worker may be passed in and it will be initialized. -export function getDebugServiceWorker( - worker: string | Worker, -): IDebugServiceWorker { - if (!wasmModule) throw "Wasm module must be loaded first"; - return createProxy(worker, wasmModule, debugServiceProtocol); -} - -export async function getCompiler(): Promise { - await instantiateWasm(); - return new Compiler(wasm); -} - -// Create the compiler inside a WebWorker and proxy requests. -// If the Worker was already created via other means and is ready to receive -// messages, then the worker may be passed in and it will be initialized. -export function getCompilerWorker(worker: string | Worker): ICompilerWorker { - if (!wasmModule) throw "Wasm module must be loaded first"; - return createProxy(worker, wasmModule, compilerProtocol); -} - -export async function getLanguageService( - host?: IProjectHost, -): Promise { - await instantiateWasm(); - return new QSharpLanguageService(wasm, host); -} - -// Create the compiler inside a WebWorker and proxy requests. -// If the Worker was already created via other means and is ready to receive -// messages, then the worker may be passed in and it will be initialized. -export function getLanguageServiceWorker( - worker: string | Worker, -): ILanguageServiceWorker { - if (!wasmModule) throw "Wasm module must be loaded first"; - return createProxy(worker, wasmModule, languageServiceProtocol); -} - -/// Extracts the target profile from a Q# source file's entry point. -/// Scans the provided source code for an EntryPoint argument specifying -/// a profile and returns the corresponding TargetProfile value, if found. -/// Returns undefined if no profile is specified or if the profile is not recognized. -export async function getTargetProfileFromEntryPoint( - fileName: string, - source: string, -): Promise { - await instantiateWasm(); - return callAndTransformExceptions( - async () => - wasm.get_target_profile_from_entry_point(fileName, source) as - | wasm.TargetProfile - | undefined, - ); -} - -export { StepResultId } from "../lib/web/qsc_wasm.js"; -export type { - IBreakpointSpan, - ICodeAction, - ICodeLens, - IDocFile, - ILocation, - IOperationInfo, - IPosition, - IProjectConfig, - IProjectHost, - IQSharpError, - IRange, - IStackFrame, - IStructStepResult, - ITestDescriptor, - IVariable, - IVariableChild, - IWorkspaceEdit, - VSDiagnostic, -} from "../lib/web/qsc_wasm.js"; -export { type Dump, type ShotResult } from "./compiler/common.js"; -export { type CompilerState, type ProgramConfig } from "./compiler/compiler.js"; -export { QscEventTarget } from "./compiler/events.js"; -export { QdkDiagnostics } from "./diagnostics.js"; -export type { - LanguageServiceDiagnosticEvent, - LanguageServiceEvent, - LanguageServiceTestCallablesEvent, -} from "./language-service/language-service.js"; -export { default as openqasm_samples } from "./openqasm-samples.generated.js"; -export type { ProjectLoader } from "./project.js"; -export { default as samples } from "./samples.generated.js"; -export type { CircuitGroup as CircuitData } from "./data-structures/circuit.js"; -export * as utils from "./utils.js"; -export { log, type LogLevel, type ProjectType, type TargetProfile }; -export type { - ICompiler, - ICompilerWorker, - IDebugService, - IDebugServiceWorker, - ILanguageService, - ILanguageServiceWorker, -}; diff --git a/source/npm/qsharp/src/common-exports.ts b/source/npm/qsharp/src/common-exports.ts new file mode 100644 index 0000000000..1205ada4b8 --- /dev/null +++ b/source/npm/qsharp/src/common-exports.ts @@ -0,0 +1,29 @@ +export type { IVariable, IVariableChild } from "../lib/web/qsc_wasm.js"; +export * as utils from "./utils.js"; +export { log } from "./log.js"; +export { QscEventTarget } from "./compiler/events.js"; +export { QdkDiagnostics } from "./diagnostics.js"; +export { default as samples } from "./samples.generated.js"; +export { default as openqasm_samples } from "./openqasm-samples.generated.js"; +export { + qsharpGithubUriScheme, + qsharpLibraryUriScheme, +} from "./language-service/language-service.js"; +export type { Dump, ShotResult } from "./compiler/common.js"; +export type { CompilerState, ProgramConfig } from "./compiler/compiler.js"; +export type { ICompiler, ICompilerWorker } from "./compiler/compiler.js"; +export type { + IDebugService, + IDebugServiceWorker, +} from "./debug-service/debug-service.js"; +export type { + ILanguageService, + ILanguageServiceWorker, + LanguageServiceDiagnosticEvent, + LanguageServiceEvent, + LanguageServiceTestCallablesEvent, +} from "./language-service/language-service.js"; +export type { ProjectLoader } from "./project.js"; +export type { CircuitGroup as CircuitData } from "./data-structures/circuit.js"; +export type { LogLevel } from "./log.js"; +export { StepResultId } from "../lib/web/qsc_wasm.js"; diff --git a/source/npm/qsharp/src/compiler/compiler.ts b/source/npm/qsharp/src/compiler/compiler.ts index 7d8115334c..1a175e7f0e 100644 --- a/source/npm/qsharp/src/compiler/compiler.ts +++ b/source/npm/qsharp/src/compiler/compiler.ts @@ -30,8 +30,6 @@ import { } from "./events.js"; import { callAndTransformExceptions } from "../diagnostics.js"; -// The wasm types generated for the node.js bundle are just the exported APIs, -// so use those as the set used by the shared compiler type Wasm = typeof import("../../lib/web/qsc_wasm.js"); // These need to be async/promise results for when communicating across a WebWorker, however diff --git a/source/npm/qsharp/src/compiler/worker-node.ts b/source/npm/qsharp/src/compiler/worker-node.ts deleted file mode 100644 index 218eb3d303..0000000000 --- a/source/npm/qsharp/src/compiler/worker-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createWorker } from "../workers/node.js"; -import { compilerProtocol } from "./compiler.js"; - -createWorker(compilerProtocol); diff --git a/source/npm/qsharp/src/compiler/worker-browser.ts b/source/npm/qsharp/src/compiler/worker.ts similarity index 55% rename from source/npm/qsharp/src/compiler/worker-browser.ts rename to source/npm/qsharp/src/compiler/worker.ts index dc34d6509b..16ac45bff1 100644 --- a/source/npm/qsharp/src/compiler/worker-browser.ts +++ b/source/npm/qsharp/src/compiler/worker.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { createWorker } from "../workers/browser.js"; +import { createWorker } from "../workers/worker.js"; import { compilerProtocol } from "./compiler.js"; -// This export should be assigned to 'self.onmessage' in a WebWorker +// message handler exported for backwards compatibility export const messageHandler = createWorker(compilerProtocol); +addEventListener("message", messageHandler); diff --git a/source/npm/qsharp/src/debug-service/debug-service.ts b/source/npm/qsharp/src/debug-service/debug-service.ts index 96633ed8cf..ea17399bad 100644 --- a/source/npm/qsharp/src/debug-service/debug-service.ts +++ b/source/npm/qsharp/src/debug-service/debug-service.ts @@ -13,7 +13,7 @@ import type { IStructStepResult, IVariable, } from "../../lib/web/qsc_wasm.js"; -import { ProgramConfig } from "../browser.js"; +import { ProgramConfig } from "../main.js"; import { eventStringToMsg } from "../compiler/common.js"; import { IQscEventTarget, diff --git a/source/npm/qsharp/src/debug-service/worker-node.ts b/source/npm/qsharp/src/debug-service/worker-node.ts deleted file mode 100644 index 6ec5e3b4b0..0000000000 --- a/source/npm/qsharp/src/debug-service/worker-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createWorker } from "../workers/node.js"; -import { debugServiceProtocol } from "./debug-service.js"; - -createWorker(debugServiceProtocol); diff --git a/source/npm/qsharp/src/debug-service/worker-browser.ts b/source/npm/qsharp/src/debug-service/worker.ts similarity index 56% rename from source/npm/qsharp/src/debug-service/worker-browser.ts rename to source/npm/qsharp/src/debug-service/worker.ts index 371397f2be..1e03081d8a 100644 --- a/source/npm/qsharp/src/debug-service/worker-browser.ts +++ b/source/npm/qsharp/src/debug-service/worker.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { createWorker } from "../workers/browser.js"; +import { createWorker } from "../workers/worker.js"; import { debugServiceProtocol } from "./debug-service.js"; -// This export should be assigned to 'self.onmessage' in a WebWorker +// message handler exported for backwards compatibility export const messageHandler = createWorker(debugServiceProtocol); +addEventListener("message", messageHandler); diff --git a/source/npm/qsharp/src/language-service/language-service.ts b/source/npm/qsharp/src/language-service/language-service.ts index 3f1415007b..db33acddf0 100644 --- a/source/npm/qsharp/src/language-service/language-service.ts +++ b/source/npm/qsharp/src/language-service/language-service.ts @@ -18,7 +18,7 @@ import type { VSDiagnostic, ITestDescriptor, } from "../../lib/web/qsc_wasm.js"; -import { IProjectHost } from "../browser.js"; +import { IProjectHost } from "../main.js"; import { log } from "../log.js"; import { IServiceEventTarget, diff --git a/source/npm/qsharp/src/language-service/worker-node.ts b/source/npm/qsharp/src/language-service/worker-node.ts deleted file mode 100644 index 95eddd488b..0000000000 --- a/source/npm/qsharp/src/language-service/worker-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createWorker } from "../workers/node.js"; -import { languageServiceProtocol } from "./language-service.js"; - -createWorker(languageServiceProtocol); diff --git a/source/npm/qsharp/src/language-service/worker-browser.ts b/source/npm/qsharp/src/language-service/worker.ts similarity index 57% rename from source/npm/qsharp/src/language-service/worker-browser.ts rename to source/npm/qsharp/src/language-service/worker.ts index 1f414b8110..dc25d12e7c 100644 --- a/source/npm/qsharp/src/language-service/worker-browser.ts +++ b/source/npm/qsharp/src/language-service/worker.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { createWorker } from "../workers/browser.js"; +import { createWorker } from "../workers/worker.js"; import { languageServiceProtocol } from "./language-service.js"; -// This export should be assigned to 'self.onmessage' in a WebWorker +// message handler exported for backwards compatibility export const messageHandler = createWorker(languageServiceProtocol); +addEventListener("message", messageHandler); diff --git a/source/npm/qsharp/src/log.ts b/source/npm/qsharp/src/log.ts index ab2f952db3..402acbf172 100644 --- a/source/npm/qsharp/src/log.ts +++ b/source/npm/qsharp/src/log.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Logging infrastructure for JavaScript environments (e.g. browser and node.js) +// Logging infrastructure shared across browser and Node.js environments // // Ideally this should be the only module to have global side effects and run code // on module load (i.e. other modules should consist almost entirely of declarations diff --git a/source/npm/qsharp/src/main.ts b/source/npm/qsharp/src/main.ts index 2256270dcf..9e9f2abdfb 100644 --- a/source/npm/qsharp/src/main.ts +++ b/source/npm/qsharp/src/main.ts @@ -1,10 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// This module is the main entry point for use in Node.js environments. For browser environments, -// the "./browser.js" file is the entry point module. - -import { createRequire } from "node:module"; +// This module is the single entry point for both browser and Node.js environments. + +import * as wasm from "../lib/web/qsc_wasm.js"; +import initWasm, { + IProjectHost, + ProjectType, + TargetProfile, +} from "../lib/web/qsc_wasm.js"; import { Compiler, ICompiler, @@ -17,79 +21,190 @@ import { QSharpDebugService, debugServiceProtocol, } from "./debug-service/debug-service.js"; +import { callAndTransformExceptions } from "./diagnostics.js"; import { ILanguageService, ILanguageServiceWorker, QSharpLanguageService, languageServiceProtocol, - qsharpLibraryUriScheme, } from "./language-service/language-service.js"; import { log } from "./log.js"; -import { createProxy } from "./workers/node.js"; import { ProjectLoader } from "./project.js"; -import type { IProjectHost } from "./browser.js"; +import { createProxy } from "./workers/main.js"; -export { qsharpLibraryUriScheme }; +// Create once. A module is stateless and can be efficiently passed to WebWorkers. +let wasmModule: WebAssembly.Module | null = null; +let wasmModulePromise: Promise | null = null; -// Only load the Wasm module when first needed, as it may only be used in a Worker, -// and not in the main thread. +// Getter for wasmModule that works across CJS/ESM boundaries. +// Direct `export let` live bindings don't survive CJS bundling. +export function getWasmModule(): WebAssembly.Module { + if (!wasmModule) throw "Wasm module must be loaded first"; + return wasmModule; +} -// Use the types from the web version for... reasons. -type Wasm = typeof import("../lib/web/qsc_wasm.js"); -let wasm: Wasm | null = null; +// Used to track if an instance is already instantiated +let wasmInstancePromise: Promise | null = null; + +async function wasmLoader(uriOrBuffer: string | ArrayBuffer) { + if (typeof uriOrBuffer === "string") { + log.info("Fetching wasm module from %s", uriOrBuffer); + performance.mark("fetch-wasm-start"); + const wasmRequst = await fetch(uriOrBuffer); + const wasmBuffer = await wasmRequst.arrayBuffer(); + const fetchTiming = performance.measure("fetch-wasm", "fetch-wasm-start"); + log.logTelemetry({ + id: "fetch-wasm", + data: { + duration: fetchTiming.duration, + uri: uriOrBuffer, + }, + }); + + wasmModule = await WebAssembly.compile(wasmBuffer); + } else { + log.info("Compiling wasm module from provided buffer"); + wasmModule = await WebAssembly.compile(uriOrBuffer); + } +} -function ensureWasm() { - if (!wasm) { - wasm = require("../lib/nodejs/qsc_wasm.cjs") as Wasm; - // Set up logging and telemetry as soon as possible after instantiating - wasm.initLogging(log.logWithLevel, log.getLogLevel()); - log.onLevelChanged = (level) => wasm?.setLogLevel(level); +export function loadWasmModule( + uriOrBuffer: string | ArrayBuffer, +): Promise { + // Only initiate if not already in flight, to avoid race conditions + if (!wasmModulePromise) { + wasmModulePromise = wasmLoader(uriOrBuffer); } + return wasmModulePromise; } -const require = createRequire(import.meta.url); +export async function instantiateWasm() { + // Ensure loading the module has been initiated, and wait for it. + if (!wasmModulePromise) throw "Wasm module must be loaded first"; + await wasmModulePromise; + if (!wasmModule) throw "Wasm module failed to load"; + + if (wasmInstancePromise) { + // Either in flight or already complete. The prior request will do the init, + // so just wait on that. + await wasmInstancePromise; + return; + } + + // Set the promise to signal this is in flight, then wait on the result. + wasmInstancePromise = initWasm({ module_or_path: wasmModule }); + await wasmInstancePromise; + + // Once ready, set up logging and telemetry as soon as possible after instantiating + wasm.initLogging(log.logWithLevel, log.getLogLevel()); + log.onLevelChanged = (level) => wasm.setLogLevel(level); +} export async function getLibrarySourceContent( path: string, ): Promise { - ensureWasm(); - return wasm!.get_library_source_content(path); + await instantiateWasm(); + return wasm.get_library_source_content(path); } -export function getCompiler(): ICompiler { - ensureWasm(); - return new Compiler(wasm!); +export async function getDebugService(): Promise { + await instantiateWasm(); + return new QSharpDebugService(wasm); } -export function getProjectLoader(host: IProjectHost): ProjectLoader { - ensureWasm(); - return new ProjectLoader(wasm!, host); +export async function getProjectLoader( + host: IProjectHost, +): Promise { + await instantiateWasm(); + return new ProjectLoader(wasm, host); } -export function getCompilerWorker(): ICompilerWorker { - return createProxy("../compiler/worker-node.js", compilerProtocol); +// Create the debugger inside a WebWorker and proxy requests. +// If the Worker was already created via other means and is ready to receive +// messages, then the worker may be passed in and it will be initialized. +export function getDebugServiceWorker( + worker: string | Worker, + isWorkerModule = false, +): IDebugServiceWorker { + if (!wasmModule) throw "Wasm module must be loaded first"; + return createProxy(worker, wasmModule, debugServiceProtocol, isWorkerModule); } -export function getDebugService(): IDebugService { - ensureWasm(); - return new QSharpDebugService(wasm!); +export async function getCompiler(): Promise { + await instantiateWasm(); + return new Compiler(wasm); } -export function getDebugServiceWorker(): IDebugServiceWorker { - return createProxy("../debug-service/worker-node.js", debugServiceProtocol); +// Create the compiler inside a WebWorker and proxy requests. +// If the Worker was already created via other means and is ready to receive +// messages, then the worker may be passed in and it will be initialized. +export function getCompilerWorker( + worker: string | Worker, + isWorkerModule = false, +): ICompilerWorker { + if (!wasmModule) throw "Wasm module must be loaded first"; + return createProxy(worker, wasmModule, compilerProtocol, isWorkerModule); } -export function getLanguageService(host?: IProjectHost): ILanguageService { - ensureWasm(); - return new QSharpLanguageService(wasm!, host); +export async function getLanguageService( + host?: IProjectHost, +): Promise { + await instantiateWasm(); + return new QSharpLanguageService(wasm, host); } -export function getLanguageServiceWorker(): ILanguageServiceWorker { +// Create the compiler inside a WebWorker and proxy requests. +// If the Worker was already created via other means and is ready to receive +// messages, then the worker may be passed in and it will be initialized. +export function getLanguageServiceWorker( + worker: string | Worker, + isWorkerModule = false, +): ILanguageServiceWorker { + if (!wasmModule) throw "Wasm module must be loaded first"; return createProxy( - "../language-service/worker-node.js", + worker, + wasmModule, languageServiceProtocol, + isWorkerModule, + ); +} + +/// Extracts the target profile from a Q# source file's entry point. +/// Scans the provided source code for an EntryPoint argument specifying +/// a profile and returns the corresponding TargetProfile value, if found. +/// Returns undefined if no profile is specified or if the profile is not recognized. +export async function getTargetProfileFromEntryPoint( + fileName: string, + source: string, +): Promise { + await instantiateWasm(); + return callAndTransformExceptions( + async () => + wasm.get_target_profile_from_entry_point(fileName, source) as + | wasm.TargetProfile + | undefined, ); } -export * as utils from "./utils.js"; -export type { IVariable, IVariableChild } from "./browser.js"; +export { StepResultId } from "../lib/web/qsc_wasm.js"; +export type { + IBreakpointSpan, + ICodeAction, + ICodeLens, + IDocFile, + ILocation, + IOperationInfo, + IPosition, + IProjectConfig, + IProjectHost, + IQSharpError, + IRange, + IStackFrame, + IStructStepResult, + ITestDescriptor, + IWorkspaceEdit, + VSDiagnostic, +} from "../lib/web/qsc_wasm.js"; +export type { ProjectType, TargetProfile }; + +export * from "./common-exports.js"; diff --git a/source/npm/qsharp/src/workers/main.ts b/source/npm/qsharp/src/workers/main.ts new file mode 100644 index 0000000000..02848819d4 --- /dev/null +++ b/source/npm/qsharp/src/workers/main.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log } from "../log.js"; +import { + IServiceEventMessage, + IServiceProxy, + ServiceMethods, + ServiceProtocol, + createProxyInternal, +} from "./common.js"; +import Worker from "web-worker"; + +/** + * Creates and initializes a service in a web worker, and returns a proxy for the service + * to be used from the main thread. + * + * @param workerArg The service web worker or the URL of the web worker script. + * @param wasmModule The wasm module to initialize the service with + * @param serviceProtocol An object that describes the service: its constructor, methods and events + * @returns A proxy object that implements the service interface. + * This interface can now be used as if calling into the real service, + * and the calls will be proxied to the web worker. + */ +export function createProxy< + TService extends ServiceMethods, + TServiceEventMsg extends IServiceEventMessage, +>( + workerArg: string | Worker, + wasmModule: WebAssembly.Module, + serviceProtocol: ServiceProtocol, + isWorkerModule = false, +): TService & IServiceProxy { + // Create or use the WebWorker + const worker = + typeof workerArg === "string" + ? new Worker(workerArg, { type: isWorkerModule ? "module" : "classic" }) + : workerArg; + + // Log any errors from the worker + worker.addEventListener("error", (ev: Event) => { + log.error("Worker error:", ev); + }); + + // Send it the Wasm module to instantiate + worker.postMessage({ + type: "init", + wasmModule, + qscLogLevel: log.getLogLevel(), + }); + + // If you lose the 'this' binding, some environments have issues + const postMessage = worker.postMessage.bind(worker); + const onTerminate = () => worker.terminate(); + + // Create the proxy which will forward method calls to the worker + const proxy = createProxyInternal( + postMessage, + onTerminate, + serviceProtocol.methods, + ); + + // Let proxy handle response and event messages from the worker + worker.addEventListener("message", (ev: MessageEvent) => { + proxy.onMsgFromWorker(ev.data); + }); + return proxy; +} diff --git a/source/npm/qsharp/src/workers/node.ts b/source/npm/qsharp/src/workers/node.ts deleted file mode 100644 index f7be988fff..0000000000 --- a/source/npm/qsharp/src/workers/node.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - Worker, - isMainThread, - parentPort, - workerData, -} from "node:worker_threads"; -import * as wasm from "../../lib/nodejs/qsc_wasm.cjs"; -import { log } from "../log.js"; -import { - IServiceEventMessage, - IServiceProxy, - ServiceMethods, - ServiceProtocol, - createProxyInternal, - initService, -} from "./common.js"; - -/** - * Creates an initializes a service, setting it up to receive requests. - * This function to be is used in the worker. - * - * @param serviceProtocol An object that describes the service: its constructor, methods and events - */ -export function createWorker< - TService extends ServiceMethods, - TServiceEventMsg extends IServiceEventMessage, ->(protocol: ServiceProtocol): void { - if (isMainThread) - throw "Worker script should be loaded in a Worker thread only"; - - const port = parentPort!; - - const postMessage = port.postMessage.bind(port); - - const invokeService = initService( - postMessage, - protocol, - wasm as any, // Need to cast due to difference in web and node wasm types - workerData && typeof workerData.qscLogLevel === "number" - ? workerData.qscLogLevel - : undefined, - ); - - function messageHandler(data: any) { - if (!data.type || typeof data.type !== "string") { - log.error(`Unrecognized msg: %O"`, data); - return; - } - - invokeService(data); - } - - port.addListener("message", messageHandler); -} - -/** - * Creates and initializes a service in a worker thread, and returns a proxy for the service - * to be used from the main thread. - * - * @param workerArg The the URL of the service web worker script. - * @param wasmModule The wasm module to initialize the service with - * @param serviceProtocol An object that describes the service: its constructor, methods and events - * @returns A proxy object that implements the service interface. - * This interface can now be used as if calling into the real service, - * and the calls will be proxied to the web worker. - */ -export function createProxy< - TService extends ServiceMethods, - TServiceEventMsg extends IServiceEventMessage, ->( - workerArg: string, - serviceProtocol: ServiceProtocol, -): TService & IServiceProxy { - const thisDir = dirname(fileURLToPath(import.meta.url)); - const worker = new Worker(join(thisDir, workerArg), { - workerData: { qscLogLevel: log.getLogLevel() }, - }); - - // Create the proxy which will forward method calls to the worker - const proxy = createProxyInternal( - // If you lose the 'this' binding, some environments have issues. - worker.postMessage.bind(worker), - () => worker.terminate(), - serviceProtocol.methods, - ); - - // Let proxy handle response and event messages from the worker - worker.addListener("message", proxy.onMsgFromWorker); - - return proxy; -} diff --git a/source/npm/qsharp/src/workers/browser.ts b/source/npm/qsharp/src/workers/worker.ts similarity index 51% rename from source/npm/qsharp/src/workers/browser.ts rename to source/npm/qsharp/src/workers/worker.ts index 2703d30c29..d96cadc2e5 100644 --- a/source/npm/qsharp/src/workers/browser.ts +++ b/source/npm/qsharp/src/workers/worker.ts @@ -5,11 +5,9 @@ import * as wasm from "../../lib/web/qsc_wasm.js"; import { log } from "../log.js"; import { IServiceEventMessage, - IServiceProxy, RequestMessage, ServiceMethods, ServiceProtocol, - createProxyInternal, initService, } from "./common.js"; @@ -63,49 +61,3 @@ export function createWorker< } }; } - -/** - * Creates and initializes a service in a web worker, and returns a proxy for the service - * to be used from the main thread. - * - * @param workerArg The service web worker or the URL of the web worker script. - * @param wasmModule The wasm module to initialize the service with - * @param serviceProtocol An object that describes the service: its constructor, methods and events - * @returns A proxy object that implements the service interface. - * This interface can now be used as if calling into the real service, - * and the calls will be proxied to the web worker. - */ -export function createProxy< - TService extends ServiceMethods, - TServiceEventMsg extends IServiceEventMessage, ->( - workerArg: string | Worker, - wasmModule: WebAssembly.Module, - serviceProtocol: ServiceProtocol, -): TService & IServiceProxy { - // Create or use the WebWorker - const worker = - typeof workerArg === "string" ? new Worker(workerArg) : workerArg; - - // Send it the Wasm module to instantiate - worker.postMessage({ - type: "init", - wasmModule, - qscLogLevel: log.getLogLevel(), - }); - - // If you lose the 'this' binding, some environments have issues - const postMessage = worker.postMessage.bind(worker); - const onTerminate = () => worker.terminate(); - - // Create the proxy which will forward method calls to the worker - const proxy = createProxyInternal( - postMessage, - onTerminate, - serviceProtocol.methods, - ); - - // Let proxy handle response and event messages from the worker - worker.onmessage = (ev) => proxy.onMsgFromWorker(ev.data); - return proxy; -} diff --git a/source/npm/qsharp/test/basics.js b/source/npm/qsharp/test/basics.js index 07c7a35b7d..067e2549c5 100644 --- a/source/npm/qsharp/test/basics.js +++ b/source/npm/qsharp/test/basics.js @@ -12,6 +12,7 @@ import { getLanguageService, getLanguageServiceWorker, getDebugServiceWorker, + loadWasmModule, utils, } from "../dist/main.js"; @@ -19,6 +20,19 @@ import { QscEventTarget } from "../dist/compiler/events.js"; import { getAllKatas, getExerciseSources, getKata } from "../dist/katas.js"; import samples from "../dist/samples.generated.js"; +import { readFileSync } from "node:fs"; + +const distDir = new URL("../dist/", import.meta.url); +const compilerWorkerPath = new URL("compiler/worker.js", distDir).href; +const languageServiceWorkerPath = new URL("language-service/worker.js", distDir) + .href; +const debugServiceWorkerPath = new URL("debug-service/worker.js", distDir).href; +const useWorkerModule = true; + +// Load the wasm module before running any tests +const wasmPath = new URL("../lib/web/qsc_wasm_bg.wasm", import.meta.url); +await loadWasmModule(readFileSync(wasmPath).buffer); + /** @type {import("../dist/log.js").TelemetryEvent[]} */ const telemetryEvents = []; log.setLogLevel("warn"); @@ -31,27 +45,31 @@ log.setTelemetryCollector((event) => telemetryEvents.push(event)); * @param {boolean} useWorker * @returns {Promise} */ -export function runSingleShot(code, expr, useWorker) { - return new Promise((resolve, reject) => { - const resultsHandler = new QscEventTarget(true); - const compiler = useWorker ? getCompilerWorker() : getCompiler(); +export async function runSingleShot(code, expr, useWorker) { + const resultsHandler = new QscEventTarget(true); + const compiler = useWorker + ? getCompilerWorker(compilerWorkerPath, useWorkerModule) + : await getCompiler(); - compiler - .run( - { sources: [["test.qs", code]], languageFeatures: [] }, - expr, - 1, - resultsHandler, - ) - .then(() => resolve(resultsHandler.getResults()[0])) - .catch((err) => reject(err)) - /* @ts-expect-error: ICompiler does not include 'terminate' */ - .finally(() => (useWorker ? compiler.terminate() : null)); - }); + try { + await compiler.run( + { sources: [["test.qs", code]], languageFeatures: [] }, + expr, + 1, + resultsHandler, + ); + return resultsHandler.getResults()[0]; + } catch (err) { + console.error("Error during runSingleShot:", err); + throw err; + } finally { + /* @ts-expect-error: ICompiler does not include 'terminate' */ + if (useWorker) compiler.terminate(); + } } test("autogenerated documentation", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const regex = new RegExp("^qsharp.namespace: (.+)$", "m"); const docFiles = await compiler.getDocumentation(); var numberOfGoodFiles = 0; @@ -85,7 +103,7 @@ test("autogenerated documentation", async () => { }); test("library summaries slim docs", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const summaries = await compiler.getLibrarySummaries(); assert(typeof summaries === "string", "Summaries should be a string"); assert(summaries.length > 0, "Summaries should not be empty"); @@ -107,12 +125,12 @@ test("library summaries slim docs", async () => { }); test("basic eval", async () => { - let code = `namespace Test { + const code = `namespace Test { function Answer() : Int { return 42; } }`; - let expr = `Test.Answer()`; + const expr = `Test.Answer()`; const result = await runSingleShot(code, expr, false); assert(result.success); @@ -134,7 +152,7 @@ namespace Test { }); test("one syntax error", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const diags = await compiler.checkCode("namespace Foo []"); assert.equal(diags.length, 1); @@ -143,7 +161,7 @@ test("one syntax error", async () => { }); test("error with newlines", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const diags = await compiler.checkCode( "namespace input { operation Foo(a) : Unit {} }", @@ -164,27 +182,27 @@ test("error with newlines", async () => { }); test("dump and message output", async () => { - let code = `namespace Test { + const code = `namespace Test { function Answer() : Int { Microsoft.Quantum.Diagnostics.DumpMachine(); Message("hello, qsharp"); return 42; } }`; - let expr = `Test.Answer()`; + const expr = `Test.Answer()`; const result = await runSingleShot(code, expr, true); assert(result.success); - assert(result.events.length == 2); - assert(result.events[0].type == "DumpMachine"); - assert(result.events[0].state["|0⟩"].length == 2); - assert(result.events[1].type == "Message"); - assert(result.events[1].message == "hello, qsharp"); + assert(result.events.length === 2); + assert(result.events[0].type === "DumpMachine"); + assert(result.events[0].state["|0⟩"].length === 2); + assert(result.events[1].type === "Message"); + assert(result.events[1].message === "hello, qsharp"); }); async function runExerciseSolutionCheck(exercise, solution) { const evtTarget = new QscEventTarget(true); - const compiler = getCompiler(); + const compiler = await getCompiler(); const sources = await getExerciseSources(exercise); const success = await compiler.checkExerciseSolution( solution, @@ -362,7 +380,7 @@ test("worker 100 shots", async () => { let expr = `Test.Answer()`; const resultsHandler = new QscEventTarget(true); - const compiler = getCompilerWorker(); + const compiler = getCompilerWorker(compilerWorkerPath, useWorkerModule); await compiler.run( { sources: [["test.qs", code]], languageFeatures: [] }, expr, @@ -382,7 +400,7 @@ test("worker 100 shots", async () => { }); test("Run samples", async () => { - const compiler = getCompilerWorker(); + const compiler = getCompilerWorker(compilerWorkerPath, useWorkerModule); const resultsHandler = new QscEventTarget(true); const testCases = samples.filter((x) => !x.omitFromTests); @@ -403,7 +421,7 @@ test("Run samples", async () => { }); test("state change", async () => { - const compiler = getCompilerWorker(); + const compiler = getCompilerWorker(compilerWorkerPath, useWorkerModule); const resultsHandler = new QscEventTarget(false); const stateChanges = []; @@ -446,7 +464,7 @@ test("cancel worker", () => { }`; const cancelledArray = []; - const compiler = getCompilerWorker(); + const compiler = getCompilerWorker(compilerWorkerPath, useWorkerModule); const resultsHandler = new QscEventTarget(false); // Queue some tasks that will never complete @@ -473,7 +491,7 @@ test("cancel worker", () => { compiler.terminate(); // Start a new compiler and ensure that works fine - const compiler2 = getCompilerWorker(); + const compiler2 = getCompilerWorker(compilerWorkerPath, useWorkerModule); const result = await compiler2.getHir(code, []); compiler2.terminate(); @@ -490,7 +508,7 @@ test("cancel worker", () => { }); test("check code", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const diags = await compiler.checkCode("namespace Foo []"); assert.equal(diags.length, 1); @@ -499,7 +517,7 @@ test("check code", async () => { }); test("language service diagnostics", async () => { - const languageService = getLanguageService(); + const languageService = await getLanguageService(); let gotDiagnostics = false; languageService.addEventListener("diagnostics", (event) => { gotDiagnostics = true; @@ -530,7 +548,7 @@ test("language service diagnostics", async () => { }); test("test callable discovery", async () => { - const languageService = getLanguageService(); + const languageService = await getLanguageService(); let gotTests = false; languageService.addEventListener("testCallables", (event) => { gotTests = true; @@ -567,7 +585,7 @@ test("test callable discovery", async () => { }); test("multiple test callable discovery", async () => { - const languageService = getLanguageService(); + const languageService = await getLanguageService(); let gotTests = false; languageService.addEventListener("testCallables", (event) => { gotTests = true; @@ -606,7 +624,7 @@ namespace Sample2 { }); test("diagnostics with related spans", async () => { - const languageService = getLanguageService(); + const languageService = await getLanguageService(); let gotDiagnostics = false; languageService.addEventListener("diagnostics", (event) => { gotDiagnostics = true; @@ -690,7 +708,10 @@ test("diagnostics with related spans", async () => { }); test("language service diagnostics - web worker", async () => { - const languageService = getLanguageServiceWorker(); + const languageService = getLanguageServiceWorker( + languageServiceWorkerPath, + useWorkerModule, + ); let gotDiagnostics = false; languageService.addEventListener("diagnostics", (event) => { gotDiagnostics = true; @@ -722,7 +743,10 @@ test("language service diagnostics - web worker", async () => { }); test("language service configuration update", async () => { - const languageService = getLanguageServiceWorker(); + const languageService = getLanguageServiceWorker( + languageServiceWorkerPath, + useWorkerModule, + ); // Set the configuration to expect an entry point. await languageService.updateConfiguration({ packageType: "exe" }); @@ -772,7 +796,10 @@ test("language service configuration update", async () => { }); test("language service in notebook", async () => { - const languageService = getLanguageServiceWorker(); + const languageService = getLanguageServiceWorker( + languageServiceWorkerPath, + useWorkerModule, + ); let actualMessages = []; languageService.addEventListener("diagnostics", (event) => { actualMessages.push({ @@ -813,7 +840,9 @@ test("language service in notebook", async () => { }); async function testCompilerError(useWorker) { - const compiler = useWorker ? getCompilerWorker() : getCompiler(); + const compiler = useWorker + ? getCompilerWorker(compilerWorkerPath, useWorkerModule) + : await getCompiler(); if (useWorker) { // @ts-expect-error onstatechange only exists on the worker compiler.onstatechange = (state) => { @@ -854,7 +883,10 @@ test("compiler error on run", () => testCompilerError(false)); test("compiler error on run - worker", () => testCompilerError(true)); test("debug service loading source without entry point attr fails - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -883,7 +915,10 @@ test("debug service loading source without entry point attr fails - web worker", }); test("debug service loading source with syntax error fails - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -908,7 +943,10 @@ test("debug service loading source with syntax error fails - web worker", async }); test("debug service loading source with bad entry expr fails - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -927,7 +965,10 @@ test("debug service loading source with bad entry expr fails - web worker", asyn }); test("debug service loading source that doesn't match profile fails - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -949,7 +990,10 @@ test("debug service loading source that doesn't match profile fails - web worker }); test("debug service loading source with good entry expr succeeds - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -969,7 +1013,10 @@ test("debug service loading source with good entry expr succeeds - web worker", }); test("debug service loading source with entry point attr succeeds - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -1000,7 +1047,10 @@ test("debug service loading source with entry point attr succeeds - web worker", }); test("debug service getting breakpoints after loaded source succeeds when file names match - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { @@ -1032,7 +1082,10 @@ test("debug service getting breakpoints after loaded source succeeds when file n }); test("debug service compiling multiple sources - web worker", async () => { - const debugService = getDebugServiceWorker(); + const debugService = getDebugServiceWorker( + debugServiceWorkerPath, + useWorkerModule, + ); try { const result = await debugService.loadProgram( { diff --git a/source/npm/qsharp/test/circuits.js b/source/npm/qsharp/test/circuits.js index ca7fddadba..4097fcff5e 100644 --- a/source/npm/qsharp/test/circuits.js +++ b/source/npm/qsharp/test/circuits.js @@ -9,15 +9,19 @@ // @ts-check import { JSDOM } from "jsdom"; -import fs from "node:fs"; +import fs, { readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, test } from "node:test"; import { fileURLToPath } from "node:url"; import prettier from "prettier"; import { log } from "../dist/log.js"; -import { getCompiler } from "../dist/main.js"; +import { getCompiler, loadWasmModule } from "../dist/main.js"; import { draw } from "../dist/ux/circuit-vis/index.js"; +// Load the wasm module before running any tests +const wasmPath = new URL("../lib/web/qsc_wasm_bg.wasm", import.meta.url); +await loadWasmModule(readFileSync(wasmPath).buffer); + /** @type {import("../dist/log.js").TelemetryEvent[]} */ const telemetryEvents = []; log.setLogLevel("warn"); @@ -273,7 +277,7 @@ async function generateAndDrawCircuit( generationMethod, renderDepth, ) { - const compiler = getCompiler(); + const compiler = await getCompiler(); const title = document.createElement("div"); title.innerHTML = `

${id}

`; document.body.appendChild(title); diff --git a/source/npm/qsharp/test/diagnostics.js b/source/npm/qsharp/test/diagnostics.js index a801c79d83..38bfe71bbf 100644 --- a/source/npm/qsharp/test/diagnostics.js +++ b/source/npm/qsharp/test/diagnostics.js @@ -4,6 +4,7 @@ //@ts-check import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; import { QdkDiagnostics } from "../dist/diagnostics.js"; import { log } from "../dist/log.js"; @@ -11,8 +12,17 @@ import { getCompiler, getCompilerWorker, getProjectLoader, + loadWasmModule, } from "../dist/main.js"; +const distDir = new URL("../dist/", import.meta.url); +const compilerWorkerPath = new URL("compiler/worker.js", distDir).href; +const useWorkerModule = true; + +// Load the wasm module before running any tests +const wasmPath = new URL("../lib/web/qsc_wasm_bg.wasm", import.meta.url); +await loadWasmModule(readFileSync(wasmPath).buffer); + /** @type {import("../dist/log.js").TelemetryEvent[]} */ const telemetryEvents = []; log.setLogLevel("warn"); @@ -34,7 +44,7 @@ function getInvalidQirProgramConfig() { } test("getQir throws QdkDiagnostics", async () => { - const compiler = getCompiler(); + const compiler = await getCompiler(); const invalidConfig = getInvalidQirProgramConfig(); await assert.rejects( () => compiler.getQir(invalidConfig), @@ -49,7 +59,7 @@ test("getQir throws QdkDiagnostics", async () => { }); test("getQir throws QdkDiagnostics - worker", async () => { - const compiler = getCompilerWorker(); + const compiler = getCompilerWorker(compilerWorkerPath, useWorkerModule); const invalidConfig = getInvalidQirProgramConfig(); try { await assert.rejects( @@ -77,7 +87,7 @@ const dummyHost = { }; test("loadQSharpProject throws QdkDiagnostics", async () => { - const loader = getProjectLoader(dummyHost); + const loader = await getProjectLoader(dummyHost); await assert.rejects( () => loader.loadQSharpProject("/not/a/real/dir"), (err) => { diff --git a/source/npm/qsharp/test/languageService.js b/source/npm/qsharp/test/languageService.js index b265097149..60e0988d29 100644 --- a/source/npm/qsharp/test/languageService.js +++ b/source/npm/qsharp/test/languageService.js @@ -4,9 +4,14 @@ // @ts-check import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; import { log } from "../dist/log.js"; -import { getLanguageService } from "../dist/main.js"; +import { getLanguageService, loadWasmModule } from "../dist/main.js"; + +// Load the wasm module before running any tests +const wasmPath = new URL("../lib/web/qsc_wasm_bg.wasm", import.meta.url); +await loadWasmModule(readFileSync(wasmPath).buffer); log.setLogLevel("warn"); @@ -20,7 +25,7 @@ const dummyHost = { }; test("devDiagnostics configuration works", async () => { - const languageService = getLanguageService(dummyHost); + const languageService = await getLanguageService(dummyHost); try { // Collect diagnostics events as they are raised diff --git a/source/vscode/build.mjs b/source/vscode/build.mjs index 29d8d12c5d..23897fdb41 100644 --- a/source/vscode/build.mjs +++ b/source/vscode/build.mjs @@ -3,7 +3,7 @@ //@ts-check -import { copyFileSync, mkdirSync, readdirSync } from "node:fs"; +import { copyFileSync, cpSync, mkdirSync, readdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuildBuild, context } from "esbuild"; @@ -12,25 +12,21 @@ const thisDir = dirname(fileURLToPath(import.meta.url)); const libsDir = join(thisDir, "..", "..", "node_modules"); /** @type {import("esbuild").BuildOptions} */ -const buildOptions = { +const commonBuildOptions = { entryPoints: [ join(thisDir, "src", "extension.ts"), join(thisDir, "src", "compilerWorker.ts"), join(thisDir, "src", "debugger/debug-service-worker.ts"), - join(thisDir, "src", "webview/webview.tsx"), - join(thisDir, "src", "webview/editor.tsx"), ], - outdir: join(thisDir, "out"), bundle: true, - // minify: true, - mainFields: ["browser", "module", "main"], external: ["vscode"], format: "cjs", - platform: "browser", target: ["es2020"], sourcemap: "linked", - //logLevel: "debug", - define: { "import.meta.url": "undefined" }, + define: { + "import.meta.url": "undefined", + __PLATFORM__: JSON.stringify("browser"), + }, }; /** @type {import("esbuild").Plugin} */ @@ -57,15 +53,14 @@ const inlineStateComputeWorkerPlugin = { ); const result = await esbuildBuild({ + ...commonBuildOptions, entryPoints: [workerEntry], bundle: true, write: false, platform: "browser", // Blob workers are classic scripts by default (not ESM), so emit an IIFE. format: "iife", - target: buildOptions.target, sourcemap: false, - define: buildOptions.define, logLevel: "silent", }); @@ -81,20 +76,9 @@ const inlineStateComputeWorkerPlugin = { }, }; -function getTimeStr() { - const now = new Date(); - - const hh = now.getHours().toString().padStart(2, "0"); - const mm = now.getMinutes().toString().padStart(2, "0"); - const ss = now.getSeconds().toString().padStart(2, "0"); - const mil = now.getMilliseconds().toString().padStart(3, "0"); - - return `${hh}:${mm}:${ss}.${mil}`; -} - export function copyWasmToVsCode() { // Copy the wasm module into the extension directory - let qsharpWasm = join( + const qsharpWasm = join( thisDir, "..", "npm", @@ -103,9 +87,10 @@ export function copyWasmToVsCode() { "web", "qsc_wasm_bg.wasm", ); - let qsharpDest = join(thisDir, `wasm`); + const qsharpDest = join(thisDir, "wasm"); console.log("Copying the wasm file to VS Code from: " + qsharpWasm); + console.log("Destination: " + qsharpDest); mkdirSync(qsharpDest, { recursive: true }); copyFileSync(qsharpWasm, join(qsharpDest, "qsc_wasm_bg.wasm")); } @@ -115,8 +100,8 @@ export function copyWasmToVsCode() { * @param {string} [destDir] */ export function copyKatex(destDir) { - let katexBase = join(libsDir, `katex/dist`); - let katexDest = destDir ?? join(thisDir, `out/katex`); + const katexBase = join(libsDir, `katex/dist`); + const katexDest = destDir ?? join(thisDir, `out/katex`); console.log("Copying the Katex files over from: " + katexBase); mkdirSync(katexDest, { recursive: true }); @@ -167,15 +152,116 @@ export function copyKatex(destDir) { } } -function buildBundle() { - console.log("Running esbuild"); +/** + * @param {boolean} [onlyUI] + * @param {string} [platform] + * @returns {import("esbuild").BuildOptions} + */ +function getBuildOptions(onlyUI, platform) { + if (onlyUI) { + return { + ...commonBuildOptions, + platform: "browser", + outdir: join(thisDir, "out", "webview"), + entryPoints: [ + join(thisDir, "src", "webview/webview.tsx"), + join(thisDir, "src", "webview/editor.tsx"), + ], + plugins: [inlineStateComputeWorkerPlugin], + }; + } else if (platform === "browser") { + return { + ...commonBuildOptions, + platform: "browser", + outdir: join(thisDir, "out", "browser"), + }; + } else if (platform === "node") { + return { + ...commonBuildOptions, + platform: "node", + outdir: join(thisDir, "out", "node"), + entryPoints: [join(thisDir, "src", "extension.ts")], + external: ["vscode", "web-worker"], + banner: { + js: 'const _importMetaUrl = require("url").pathToFileURL(__filename).href;', + }, + define: { + "import.meta.url": "_importMetaUrl", + __PLATFORM__: JSON.stringify("node"), + }, + }; + } else if (platform === "node-worker") { + return { + ...commonBuildOptions, + platform: "node", + outdir: join(thisDir, "out", "node"), + entryPoints: [ + join(thisDir, "src", "compilerWorker.ts"), + join(thisDir, "src", "debugger/debug-service-worker.ts"), + ], + define: { + "import.meta.url": "undefined", + __PLATFORM__: JSON.stringify("node"), + }, + }; + } else { + throw new Error(`Invalid platform: ${platform}`); + } +} + +/** + * @param {boolean} [onlyUI] + * @param {string} [platform] + */ +function buildBundle(onlyUI, platform) { + console.log("Running esbuild for platform: " + platform); + const buildOptions = getBuildOptions(onlyUI, platform); + esbuildBuild(buildOptions) + .catch((err) => { + console.error("Build failed:", err); + process.exit(1); + }) + .then(() => console.log(`Built bundle to ${buildOptions.outdir}`)); +} + +function buildUI() { + copyKatex(); + buildBundle(true, "browser"); +} + +/** + * @param {string} [platform] + */ +function buildExtensionHost(platform) { + buildBundle(false, platform); +} + +/** + * Copy external node dependencies into node_modules/ under the extension + * directory so they can be resolved at runtime (e.g. when installed as a VSIX). + */ +function copyNodeExternals() { + const nodeExternals = ["web-worker"]; + for (const pkg of nodeExternals) { + const src = join(libsDir, pkg); + const dest = join(thisDir, "node_modules", pkg); + console.log(`Copying external dependency ${pkg} to ${dest}`); + cpSync(src, dest, { recursive: true }); + } +} + +function getTimeStr() { + const now = new Date(); - esbuildBuild({ - ...buildOptions, - plugins: [inlineStateComputeWorkerPlugin], - }).then(() => console.log(`Built bundle to ${join(thisDir, "out")}`)); + const hh = now.getHours().toString().padStart(2, "0"); + const mm = now.getMinutes().toString().padStart(2, "0"); + const ss = now.getSeconds().toString().padStart(2, "0"); + const mil = now.getMilliseconds().toString().padStart(3, "0"); + + return `${hh}:${mm}:${ss}.${mil}`; } +// This only watches for platform = "browser" for the sake of simplicity, so make sure to run a full build first to catch any errors in the node build before pushing code changes export async function watchVsCode() { console.log("Building vscode extension in watch mode"); @@ -192,8 +278,17 @@ export async function watchVsCode() { ); }, }; - let ctx = await context({ - ...buildOptions, + const ctx = await context({ + ...commonBuildOptions, + entryPoints: [ + join(thisDir, "src", "extension.ts"), + join(thisDir, "src", "compilerWorker.ts"), + join(thisDir, "src", "debugger/debug-service-worker.ts"), + join(thisDir, "src", "webview/webview.tsx"), + join(thisDir, "src", "webview/editor.tsx"), + ], + platform: "browser", + outdir: join(thisDir, "out", "web"), plugins: [inlineStateComputeWorkerPlugin, buildPlugin], color: false, }); @@ -208,8 +303,11 @@ if (thisFilePath === resolve(process.argv[1])) { if (isWatch) { watchVsCode(); } else { + buildUI(); copyWasmToVsCode(); - copyKatex(); - buildBundle(); + buildExtensionHost("browser"); + buildExtensionHost("node"); + buildExtensionHost("node-worker"); + copyNodeExternals(); } } diff --git a/source/vscode/package.json b/source/vscode/package.json index dbc1337adc..eec77c80c3 100644 --- a/source/vscode/package.json +++ b/source/vscode/package.json @@ -17,7 +17,8 @@ "Programming Languages", "Notebooks" ], - "browser": "./out/extension.js", + "browser": "./out/browser/extension.js", + "main": "./out/node/extension.js", "virtualWorkspaces": true, "activationEvents": [ "onNotebook:jupyter-notebook", @@ -27,6 +28,10 @@ "onFileSystem:qsharp-vfs", "onWebviewPanel:qsharp-webview" ], + "extensionKind": [ + "workspace", + "ui" + ], "contributes": { "walkthroughs": [ { diff --git a/source/vscode/src/circuit.ts b/source/vscode/src/circuit.ts index 5e71a2df77..27964931bb 100644 --- a/source/vscode/src/circuit.ts +++ b/source/vscode/src/circuit.ts @@ -26,7 +26,7 @@ import { import { getRandomGuid } from "./utils"; import { sendMessageToPanel } from "./webviewPanel"; import { ICircuitConfig, IPosition } from "../../npm/qsharp/lib/web/qsc_wasm"; -import { basename } from "./common"; +import { basename, getPlatformEnv } from "./common"; const compilerRunTimeoutMs = 1000 * 60 * 5; // 5 minutes @@ -280,7 +280,7 @@ export async function getCircuitOrErrorWithTimeout( const compilerWorkerScriptPath = Uri.joinPath( extensionUri, - "./out/compilerWorker.js", + `./out/${getPlatformEnv()}/compilerWorker.js`, ).toString(); const worker = getCompilerWorker(compilerWorkerScriptPath); diff --git a/source/vscode/src/common.ts b/source/vscode/src/common.ts index 0ae2e14e56..6f5accc525 100644 --- a/source/vscode/src/common.ts +++ b/source/vscode/src/common.ts @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +declare const __PLATFORM__: string; import { TextDocument, Uri, Range, Location } from "vscode"; import { @@ -147,7 +148,11 @@ export function toVsCodeDiagnostic(d: VSDiagnostic): vscode.Diagnostic { export function loadCompilerWorker(extensionUri: vscode.Uri): ICompilerWorker { const compilerWorkerScriptPath = vscode.Uri.joinPath( extensionUri, - "./out/compilerWorker.js", + `./out/${__PLATFORM__}/compilerWorker.js`, ).toString(); return getCompilerWorker(compilerWorkerScriptPath); } + +export function getPlatformEnv(): string { + return __PLATFORM__; +} diff --git a/source/vscode/src/compilerWorker.ts b/source/vscode/src/compilerWorker.ts index 9659220212..44600e108c 100644 --- a/source/vscode/src/compilerWorker.ts +++ b/source/vscode/src/compilerWorker.ts @@ -1,6 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { messageHandler } from "qsharp-lang/compiler-worker"; - -self.onmessage = messageHandler; +import "qsharp-lang/compiler-worker"; diff --git a/source/vscode/src/debugger/activate.ts b/source/vscode/src/debugger/activate.ts index 3143ccb8f9..33fd53c972 100644 --- a/source/vscode/src/debugger/activate.ts +++ b/source/vscode/src/debugger/activate.ts @@ -5,7 +5,7 @@ import { IDebugServiceWorker, getDebugServiceWorker, log } from "qsharp-lang"; import * as vscode from "vscode"; -import { qsharpExtensionId } from "../common"; +import { getPlatformEnv, qsharpExtensionId } from "../common"; import { clearCommandDiagnostics } from "../diagnostics"; import { getActiveQdkDocumentUri, @@ -22,7 +22,7 @@ export async function activateDebugger( ): Promise { const debugWorkerScriptPath = vscode.Uri.joinPath( context.extensionUri, - "./out/debugger/debug-service-worker.js", + `./out/${getPlatformEnv()}/debugger/debug-service-worker.js`, ); debugServiceWorkerFactory = () => diff --git a/source/vscode/src/debugger/debug-service-worker.ts b/source/vscode/src/debugger/debug-service-worker.ts index 80fd66c4fd..b61d2f0bca 100644 --- a/source/vscode/src/debugger/debug-service-worker.ts +++ b/source/vscode/src/debugger/debug-service-worker.ts @@ -1,6 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { messageHandler } from "qsharp-lang/debug-service-worker"; - -self.onmessage = messageHandler; +import "qsharp-lang/debug-service-worker"; diff --git a/source/vscode/src/documentation.ts b/source/vscode/src/documentation.ts index b644aa00c3..68212e337a 100644 --- a/source/vscode/src/documentation.ts +++ b/source/vscode/src/documentation.ts @@ -5,6 +5,7 @@ import { getCompilerWorker, IDocFile } from "qsharp-lang"; import { Uri } from "vscode"; import { sendMessageToPanel } from "./webviewPanel"; import { getActiveProgram } from "./programConfig"; +import { getPlatformEnv } from "./common"; export async function showDocumentationCommand(extensionUri: Uri) { const program = await getActiveProgram(); @@ -22,7 +23,7 @@ export async function showDocumentationCommand(extensionUri: Uri) { // Get API documentation from compiler. const compilerWorkerScriptPath = Uri.joinPath( extensionUri, - "./out/compilerWorker.js", + `./out/${getPlatformEnv()}/compilerWorker.js`, ).toString(); const worker = getCompilerWorker(compilerWorkerScriptPath); const docFiles = await worker.getDocumentation(program.programConfig); diff --git a/source/vscode/src/extension.ts b/source/vscode/src/extension.ts index 5b8ac0fee5..88a0133692 100644 --- a/source/vscode/src/extension.ts +++ b/source/vscode/src/extension.ts @@ -35,7 +35,7 @@ import { maybeShowChangelogPrompt, registerChangelogCommand, } from "./changelog.js"; -import { toVsCodeRange } from "./common.js"; +import { getPlatformEnv, toVsCodeRange } from "./common.js"; export async function activate( context: vscode.ExtensionContext, @@ -50,6 +50,12 @@ export async function activate( initOutputWindowLogger(); } + log.info(`Platform: ${getPlatformEnv()}`); + log.info( + `UI Kind: ${vscode.env.uiKind === vscode.UIKind.Web ? "Web" : "Desktop"}`, + ); + log.info(`Remote: ${vscode.env.remoteName ?? "local"}`); + log.info("Q# extension activating."); initTelemetry(context); diff --git a/source/vscode/src/qirGeneration.ts b/source/vscode/src/qirGeneration.ts index 20f9ddea48..185e648d33 100644 --- a/source/vscode/src/qirGeneration.ts +++ b/source/vscode/src/qirGeneration.ts @@ -8,7 +8,7 @@ import { TargetProfile, } from "qsharp-lang"; import * as vscode from "vscode"; -import { qsharpExtensionId } from "./common"; +import { getPlatformEnv, qsharpExtensionId } from "./common"; import { invokeAndReportCommandDiagnostics } from "./diagnostics"; import { FullProgramConfig, getActiveProgram } from "./programConfig"; import { @@ -208,7 +208,7 @@ async function getQirForActiveWindowCommand() { export function initCodegen(context: vscode.ExtensionContext) { compilerWorkerScriptPath = vscode.Uri.joinPath( context.extensionUri, - "./out/compilerWorker.js", + `./out/${getPlatformEnv()}/compilerWorker.js`, ).toString(); context.subscriptions.push( diff --git a/source/vscode/src/telemetry.ts b/source/vscode/src/telemetry.ts index 4a50fb2384..53056568e0 100644 --- a/source/vscode/src/telemetry.ts +++ b/source/vscode/src/telemetry.ts @@ -12,6 +12,7 @@ import { isOpenQasmDocument, isQdkNotebookCell, isQsharpDocument, + getPlatformEnv, } from "./common"; export enum EventType { @@ -402,6 +403,9 @@ export function sendTelemetryEvent( } function getBrowserRelease(): string { + if (getPlatformEnv() === "node") { + return `Node/${process.versions.node}`; + } if (navigator.userAgentData?.brands) { const browser = navigator.userAgentData.brands[navigator.userAgentData.brands.length - 1]; @@ -412,6 +416,9 @@ function getBrowserRelease(): string { } export function getUserAgent(): string { + if (typeof navigator === "undefined") { + return userAgentString || `Node/${process.versions.node}`; + } return userAgentString || navigator.userAgent; } diff --git a/source/vscode/src/webviewPanel.ts b/source/vscode/src/webviewPanel.ts index 7c6cfe9135..c1aa78f409 100644 --- a/source/vscode/src/webviewPanel.ts +++ b/source/vscode/src/webviewPanel.ts @@ -31,7 +31,7 @@ import { } from "./telemetry"; import { getRandomGuid } from "./utils"; import { getPauliNoiseModel, getQubitLossSetting } from "./config"; -import { qsharpExtensionId } from "./common"; +import { getPlatformEnv, qsharpExtensionId } from "./common"; import { resourceEstimateCommand } from "./estimate"; const QSharpWebViewType = "qsharp-webview"; @@ -47,7 +47,7 @@ export function registerWebViewCommands(context: ExtensionContext) { const compilerWorkerScriptPath = Uri.joinPath( context.extensionUri, - "./out/compilerWorker.js", + `./out/${getPlatformEnv()}/compilerWorker.js`, ).toString(); context.subscriptions.push(