diff --git a/.github/workflows/theia-compat.yml b/.github/workflows/theia-compat.yml index 8e2588a6a..89b9550e1 100644 --- a/.github/workflows/theia-compat.yml +++ b/.github/workflows/theia-compat.yml @@ -40,6 +40,9 @@ on: - 'packages/client-theia/**' - 'packages/data-client-theia/**' - 'packages/glsp-client-theia/**' + # Loaded by glsp-client-theia's frontend, so a change here is a change + # to what Theia loads. + - 'packages/glsp-client/**' - '.github/workflows/theia-compat.yml' # A README in those packages changes nothing Theia loads. - '!**/*.md' diff --git a/docs/adopting/packages.md b/docs/adopting/packages.md index f5775a232..d760d034e 100644 --- a/docs/adopting/packages.md +++ b/docs/adopting/packages.md @@ -15,16 +15,17 @@ reads `--`, and a name with no head spans all heads. | **Client integrations** | | | | [`@hydranium/client-theia`](../../packages/client-theia/README.md) | your host is Theia | What every head needs in a Theia application: logging, diagnostics and connection handling. | | [`@hydranium/data-client-theia`](../../packages/data-client-theia/README.md) | …and you run the data head | The data head's client side in Theia. | -| [`@hydranium/glsp-client-theia`](../../packages/glsp-client-theia/README.md) | …and you run the GLSP head | The GLSP head's client side in Theia, including the diagram widget. | +| [`@hydranium/glsp-client`](../../packages/glsp-client/README.md) | you run the GLSP head, whatever the host | The GLSP head's client side for any host: the diagram classes and the binder that mounts them. | +| [`@hydranium/glsp-client-theia`](../../packages/glsp-client-theia/README.md) | …and your host is Theia | The GLSP head's client side in Theia, including the diagram widget. | | [`@hydranium/client-browser`](../../packages/client-browser/README.md) | your host is a web page | The page side of a host whose heads run in a web worker: the worker bootstrap, the LSP handshake and the data head's port. | | **Tooling** | | | | [`@hydranium/cli`](../../packages/cli/README.md) | always, as a development dependency | `hydranium-cli`: scaffold a project, inspect a grammar, validate models headlessly, generate the transfer model, and drive a data server. | | [`@hydranium/conformance`](../../packages/conformance/README.md) | you want to check your server against the protocol | A conformance suite you run against your own server, in any test runner. | | [`@hydranium/langium`](../../packages/langium/README.md) | you import Langium at all | The one route to Langium, so your install has exactly one copy of it. | -For VS Code there is no client package; copy the client code from the -order-flow example. In a web page, the text editor and the diagram are still -copied from it. +For VS Code there is no client package beyond the diagram's; copy the rest of +the client code from the order-flow example. In a web page, the text editor is +still copied from it. ## Entry points diff --git a/docs/contributing/conventions.md b/docs/contributing/conventions.md index f80605ba6..7e3e452df 100644 --- a/docs/contributing/conventions.md +++ b/docs/contributing/conventions.md @@ -39,11 +39,15 @@ fills, never a static `node:` import. Why the split exists: ### A host-integration package's root barrel follows its upstream The neutral-root rule binds the four head-neutral packages. **A -`packages/*-theia` root barrel matches the root convention of the upstream it -integrates**, judged by what an adopter importing the bare specifier is -entitled to get: re-export the browser tier where the upstream's root is its -browser tier, and declare nothing (`export {}`) where the upstream has no root -surface. A module belongs on `common/` only when its whole dependency closure, +`packages/*-theia` root barrel, and `glsp-client`'s, matches the root +convention of the upstream it integrates**, judged by what an adopter importing +the bare specifier is entitled to get: re-export the browser tier where the +upstream's root is its browser tier, and declare nothing (`export {}`) where +the upstream has no root surface. `glsp-client` carries no platform in its +name because every GLSP host is a browser, and its root is browser-bound as +`@eclipse-glsp/client`'s is: it renders and loads stylesheets, so it needs a +bundler with a CSS loader, and `check:neutral` gates it for Node imports +only. A module belongs on `common/` only when its whole dependency closure, type-only imports included, is neutral or Theia's common tier; create the tier when a module needs it. A root re-exporting a browser tier loads only in a frontend build, so its declaration comment must not call it neutral. **A @@ -164,8 +168,9 @@ overload set. An empty interface is legitimate when the whole surface is `protected`; its oxlint `no-empty-object-type` disable carries the reason. Type parameters go where they are honest: per-URI members, not a cross-grammar slot. -**GLSP DI tokens** live in the `HydraniumTypes` registry, so a token's name -cannot collide with the type it injects. No language or per-language service +**GLSP DI tokens** live in a registry, `HydraniumTypes` on the server and +`HydraniumClientTypes` on the client, so a token's name cannot collide with the +type it injects. No language or per-language service gets a token: a diagram's language is its document's, and `modelState.For(node)` keeps nodes from other documents on their own grammar. diff --git a/docs/guides/host-in-a-browser.md b/docs/guides/host-in-a-browser.md index d2e63637a..6a90baa9c 100644 --- a/docs/guides/host-in-a-browser.md +++ b/docs/guides/host-in-a-browser.md @@ -21,7 +21,9 @@ its worker launcher. Run all heads in one worker, so they share one workspace. imports it for a seam a browser never calls. Alias nothing else. A real Node import anywhere then fails your build, which is the check you want, rather than failing at runtime in a worker with no console. If you mount a diagram, - add `loader: { '.ttf': 'dataurl' }` for GLSP's font. + add `loader: { '.ttf': 'dataurl' }` for GLSP's font. If you minify, keep + class names (`keepNames`): the log tags the framework's and sprotty's + classes by name. 2. **Give every head its own `MessagePort`.** In the page, build the `Worker` and hand it to `connectWorkerHeads` from `@hydranium/client-browser`, which @@ -134,11 +136,14 @@ const fileSystem = await persistentFileSystem({ before the connection listens, so you also see the first build, and keep only the newest lines. See `src/page/log-panel.ts`. -8. **Mount the diagram.** Connect GLSP's `BaseJsonrpcGLSPClient` over the GLSP - head's connection from `connectWorkerHeads`, wrapped with - `sendByMethodName` from `@hydranium/protocol`. Upstream's - `GLSPWebWorkerProvider` starts a worker of its own, so it is not the piece - to use. Then supply what a shell would: +8. **Mount the diagram.** Connect `HydraniumGlspClient` from + `@hydranium/glsp-client` over the GLSP head's connection from + `connectWorkerHeads`, wrapped with `sendByMethodName` from + `@hydranium/protocol`. Upstream's `GLSPWebWorkerProvider` starts a worker of + its own, so it is not the piece to use. Add a container module after + `STANDALONE_MODULE_CONFIG` that calls `createGlspClientModule` with your + logger, so a failed load and sprotty's own errors reach it instead of + vanishing. Then supply what a shell would: - size the `` to its mount, as in `#mount > svg { width: 100%; height: 100% }`, or the diagram draws only what fits in 300×150; - give the mount a height and `position: relative`, so the tool palette diff --git a/docs/guides/host-in-theia.md b/docs/guides/host-in-theia.md index 7d1f3b600..75600206e 100644 --- a/docs/guides/host-in-theia.md +++ b/docs/guides/host-in-theia.md @@ -20,7 +20,8 @@ them. - `@hydranium/client-theia`: what every head shares, such as the output-channel logger, the editor's save handling, and the backend's socket bridge; - `@hydranium/data-client-theia`: the data head's connection; -- `@hydranium/glsp-client-theia`: the diagram. +- `@hydranium/glsp-client-theia`: the diagram, over `@hydranium/glsp-client`, + whose classes it binds for you. Each head gets a frontend module and a backend module. The backend forwards the frontend's channel to the head's socket on the server, so the server runs diff --git a/docs/img/architecture.svg b/docs/img/architecture.svg index f8eb2d6b4..ddf0570fb 100644 --- a/docs/img/architecture.svg +++ b/docs/img/architecture.svg @@ -55,7 +55,7 @@ Diagram client Diagram client - glsp-client-theia + glsp-client(-theia) diff --git a/examples/order-flow/browser/README.md b/examples/order-flow/browser/README.md index f49eaed17..4e10b860d 100644 --- a/examples/order-flow/browser/README.md +++ b/examples/order-flow/browser/README.md @@ -115,7 +115,8 @@ npm --prefix examples/order-flow/browser run test:e2e ``` It asserts all three heads against the oracles' document, diagnostic, node and -edge counts, and that the console stays silent. It asserts that a drag and a +edge counts, and that the console carries nothing but the GLSP client's +model-initialized timing. It asserts that a drag and a palette create reach the `.layout` document, read back through the data head rather than from the rendered diagram, since sprotty draws a dragged node at the drop point whether or not the write landed. Further Playwright projects diff --git a/examples/order-flow/browser/package.json b/examples/order-flow/browser/package.json index c022ab0c8..b6d1d2847 100644 --- a/examples/order-flow/browser/package.json +++ b/examples/order-flow/browser/package.json @@ -36,6 +36,7 @@ "@hydranium/data-server": "^1.0.0-next", "@hydranium/example-order-flow-client": "0.0.0", "@hydranium/example-order-flow-server": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/glsp-server": "^1.0.0-next", "@hydranium/langium": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", diff --git a/examples/order-flow/browser/src/page/process-diagram.ts b/examples/order-flow/browser/src/page/process-diagram.ts index d231347c4..474ee316f 100644 --- a/examples/order-flow/browser/src/page/process-diagram.ts +++ b/examples/order-flow/browser/src/page/process-diagram.ts @@ -20,11 +20,12 @@ * # What replaces the shell * * A Theia or VS Code integration contributes a `GLSPClient` and a widget. Here - * `STANDALONE_MODULE_CONFIG` supplies GLSP's own plain-webapp modules and the - * client is built by hand over the head's connection — one line, because - * `BaseJsonrpcGLSPClient` takes any `ConnectionProvider` and the connection - * `@hydranium/client-browser` hands out over the GLSP port is one, wrapped - * with `sendByMethodName`. + * `STANDALONE_MODULE_CONFIG` supplies GLSP's own plain-webapp modules, the + * framework's `createGlspClientModule` binds its classes over the page's + * logger, and the client is built by hand over the head's connection — one + * line, because `HydraniumGlspClient` takes any `ConnectionProvider` and the + * connection `@hydranium/client-browser` hands out over the GLSP port is one, + * wrapped with `sendByMethodName`. * * Upstream's `GLSPWebWorkerProvider` looks like the piece for this and is not: * it constructs the worker itself, which would be a second worker with a second @@ -45,11 +46,11 @@ import { TYPES, createDiagramOptionsModule } from '@eclipse-glsp/client'; -import { BaseJsonrpcGLSPClient } from '@eclipse-glsp/protocol'; +import { createGlspClientModule, HydraniumGlspClient } from '@hydranium/glsp-client'; import { initializeOrderFlowProcessDiagramContainer } from '@hydranium/example-order-flow-client/lib/diagram/order-flow-process-diagram-module'; import { PROCESS_DIAGRAM_TYPE } from '@hydranium/example-order-flow-client/lib/diagram/order-flow-process-diagram-types'; import { Container, ContainerModule } from 'inversify'; -import { sendByMethodName, SystemClock } from '@hydranium/protocol'; +import { type Logger, sendByMethodName, SystemClock } from '@hydranium/protocol'; import type { MessageConnection } from 'vscode-jsonrpc/browser'; import { enableTouchDragging } from './touch-input.js'; // LAST, so esbuild emits these rules after `@eclipse-glsp/client`'s and they win @@ -104,17 +105,22 @@ const noContextMenuModule = new ContainerModule(bind => { * Resolves with a one-line report of what ended up on the page — the same shape * as the data head's line, so the three heads read as three answers about one * store rather than as three unrelated widgets. + * + * The logger carries the framework's client classes under a tag of the page's + * own, so a load failure the page itself reports does not read as a line the + * worker sent. */ export async function mountProcessDiagram( connection: MessageConnection, sourceUri: string, - onReport: (report: string) => void + onReport: (report: string) => void, + logger: Logger ): Promise { // No `connection.listen()` here: `BaseJsonrpcGLSPClient.start` calls it, and // a second call throws. GLSP's client sends message types built by its own // copy of `vscode-jsonrpc`, which this connection's copy rejects unless they // go by method name. - const glspClient = new BaseJsonrpcGLSPClient({ id: PROCESS_DIAGRAM_ELEMENT_ID, connectionProvider: sendByMethodName(connection) }); + const glspClient = new HydraniumGlspClient({ id: PROCESS_DIAGRAM_ELEMENT_ID, connectionProvider: sendByMethodName(connection) }); const diagramOptions: IDiagramOptions = { clientId: PROCESS_DIAGRAM_ELEMENT_ID, @@ -123,15 +129,21 @@ export async function mountProcessDiagram( sourceUri }; - // The diagram definition is appended LAST by - // `initializeOrderFlowProcessDiagramContainer`, after the host's modules — - // sprotty's model and view registries throw on a duplicate key, so whichever - // module registers an element type first wins by making the second throw. + // The framework's classes replace tokens GLSP's default modules bind, so + // this module goes after `STANDALONE_MODULE_CONFIG`. The diagram definition + // is appended LAST by `initializeOrderFlowProcessDiagramContainer`, after + // the host's modules — sprotty's model and view registries throw on a + // duplicate key, so whichever module registers an element type first wins + // by making the second throw. + const hydraniumModule = new ContainerModule((bind, unbind, isBound, rebind) => { + createGlspClientModule({ bind, unbind, isBound, rebind }, { logger }); + }); const container = initializeOrderFlowProcessDiagramContainer( new Container(), createDiagramOptionsModule(diagramOptions), STANDALONE_MODULE_CONFIG, - noContextMenuModule + noContextMenuModule, + hydraniumModule ); await container.get(DiagramLoader).load(); diff --git a/examples/order-flow/browser/src/page/workbench.ts b/examples/order-flow/browser/src/page/workbench.ts index 769a1f1d9..bf9d71342 100644 --- a/examples/order-flow/browser/src/page/workbench.ts +++ b/examples/order-flow/browser/src/page/workbench.ts @@ -1001,7 +1001,7 @@ export async function main(locale: Locale | undefined): Promise { // The callback keeps the line current as elements are created and deleted; // the resolved value is the first reading. A report written once would go // stale on the first palette gesture, beside a layout report that does not. - setGlspReport(await mountProcessDiagram(channels.heads.glsp, GLSP_HEAD_DOCUMENT, setGlspReport)); + setGlspReport(await mountProcessDiagram(channels.heads.glsp, GLSP_HEAD_DOCUMENT, setGlspReport, pageLogger.with('GLSP client'))); // AFTER the mount, and the ordering is the whole of it: a listener put on // this element before the diagram takes it over never fires — measured, // the same registration moved above this line stops working. diff --git a/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts b/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts index 518f71671..99e11f8a5 100644 --- a/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts +++ b/examples/order-flow/browser/test/e2e/order-flow-browser.spec.mts @@ -318,15 +318,22 @@ test.describe('order-flow in a web worker', () => { // inside it both carry the class. await expect(page.locator(`${GRAPH} g.sprotty-edge`)).toHaveCount(EXPECTED_EDGE_COUNT); - // SILENT, not merely error-free, and that stricter form is affordable - // because it is currently true: the page binds the one host service GLSP - // would otherwise warn about on every container build. The console is this - // host's only log — the worker posts its failures on the global channel and - // the language server's lines arrive over the LSP one — so a warning that - // means nothing is worse here than elsewhere: it trains the reader to skim - // the one place real failures appear. A new dependency that warns should - // have to be looked at rather than absorbed. - expect(consoleOutput).toEqual([]); + // EXACTLY the framework's two milestone lines, not merely error-free, and + // that stricter form is affordable because it is currently true: the page + // binds the one host service GLSP would otherwise warn about on every + // container build. The console is this host's only log — the worker posts + // its failures on the global channel and the language server's lines + // arrive over the LSP one — so a warning that means nothing is worse here + // than elsewhere: it trains the reader to skim the one place real failures + // appear. A new dependency that warns should have to be looked at rather + // than absorbed. The two lines are the GLSP client's model-initialized + // timing, written through the page's logger under the client's own tag, + // apart from the head's; they are asserted rather than filtered, so a + // framework line that stops arriving is seen too. + expect(consoleOutput).toEqual([ + expect.stringMatching(/^info: \[Info {2}- [\d:.]+\] \[GLSP client\] Model initialized \[#\d+ start\]$/), + expect.stringMatching(/^info: \[Info {2}- [\d:.]+\] \[GLSP client\] Model initialized \[#\d+ done, [\d.]+m?s\]$/) + ]); }); test('the narrow layout leaves no control behind on a workbench viewport', async ({ page }) => { diff --git a/examples/order-flow/client/README.md b/examples/order-flow/client/README.md index d7c245e3c..12600b413 100644 --- a/examples/order-flow/client/README.md +++ b/examples/order-flow/client/README.md @@ -9,8 +9,10 @@ order-flow-client diagram definition + the properties model | both halves of the post-message hop) | adapter Theia -> @hydranium/glsp-client-theia / data-client-theia / client-theia -VS Code -> @eclipse-glsp/vscode-integration, plus a hand-rolled data hop -Browser -> MessagePorts to heads in a web worker, no host framework +VS Code -> @eclipse-glsp/vscode-integration + @hydranium/glsp-client, + plus a hand-rolled data hop +Browser -> MessagePorts to heads in a web worker + @hydranium/glsp-client, + no host framework | order-flow-theia / order-flow-vscode / order-flow-browser thin shells ``` diff --git a/examples/order-flow/theia/package.json b/examples/order-flow/theia/package.json index 49e088e3e..ad89e9b62 100644 --- a/examples/order-flow/theia/package.json +++ b/examples/order-flow/theia/package.json @@ -31,6 +31,7 @@ "@hydranium/client-theia": "^1.0.0-next", "@hydranium/data-client-theia": "^1.0.0-next", "@hydranium/example-order-flow-client": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/glsp-client-theia": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.72.0", diff --git a/examples/order-flow/theia/src/browser/order-flow-process-diagram-configuration.ts b/examples/order-flow/theia/src/browser/order-flow-process-diagram-configuration.ts index f73685cda..f72d51e30 100644 --- a/examples/order-flow/theia/src/browser/order-flow-process-diagram-configuration.ts +++ b/examples/order-flow/theia/src/browser/order-flow-process-diagram-configuration.ts @@ -7,7 +7,7 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { type ContainerConfiguration, ConsoleLogger, LogLevel, TYPES } from '@eclipse-glsp/client'; +import { type ContainerConfiguration } from '@eclipse-glsp/client'; import { initializeOrderFlowProcessDiagramContainer } from '@hydranium/example-order-flow-client/lib/diagram/order-flow-process-diagram-module'; import { AbstractHydraniumGlspDiagramConfiguration, createGlspClientTheiaModule } from '@hydranium/glsp-client-theia/browser'; import { type Container, ContainerModule } from '@theia/core/shared/inversify'; @@ -55,13 +55,7 @@ export class OrderFlowProcessDiagramConfiguration extends AbstractHydraniumGlspD * The log THRESHOLD is not set here — it is a process-global applied once from * the frontend module via `logLevelPreference`, whereas this module is * instantiated per diagram container. - * - * `TYPES.ILogger` is rebound by hand because the framework offers no helper for - * it; sprotty's default logger is otherwise silent about view-registration - * failures, which are exactly the errors a diagram integration produces. */ const orderFlowProcessTheiaModule = new ContainerModule((bind, unbind, isBound, rebind) => { - rebind(TYPES.ILogger).to(ConsoleLogger).inSingletonScope(); - rebind(TYPES.LogLevel).toConstantValue(LogLevel.warn); createGlspClientTheiaModule({ bind, unbind, isBound, rebind }, { channelLogger: { channelName: ORDER_FLOW_OUTPUT_CHANNEL } }); }); diff --git a/examples/order-flow/vscode/package.json b/examples/order-flow/vscode/package.json index 0f403f9ee..ad8b5f176 100644 --- a/examples/order-flow/vscode/package.json +++ b/examples/order-flow/vscode/package.json @@ -39,6 +39,7 @@ "@hydranium/example-order-flow-client": "0.0.0", "@hydranium/example-order-flow-server": "0.0.0", "@hydranium/example-order-flow-vscode-servers": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "inversify": "^6.1.3", "vscode-jsonrpc": "9.0.0", diff --git a/examples/order-flow/vscode/src/webview/diagram.ts b/examples/order-flow/vscode/src/webview/diagram.ts index d36e18e13..ce9c62dd6 100644 --- a/examples/order-flow/vscode/src/webview/diagram.ts +++ b/examples/order-flow/vscode/src/webview/diagram.ts @@ -13,7 +13,9 @@ * Almost nothing on top of `GLSPStarter`, which already owns the whole handshake: * acquiring the VS Code API, announcing readiness, taking the diagram identifier * the endpoint sends, building a container and instantiating the widget. What an - * adopter supplies is only which container to build. + * adopter supplies is only which container to build, and the framework's + * client module, which reports a failed load and sprotty's errors on the + * webview's console instead of dropping them. * * **The import discipline is the opposite of the properties bundle's, and that * is the point.** `properties.ts` reaches into client MODULE paths precisely to @@ -31,7 +33,8 @@ import { GLSPStarter } from '@eclipse-glsp/vscode-integration-webview'; import { initializeOrderFlowProcessDiagramContainer } from '@hydranium/example-order-flow-client/lib/diagram/order-flow-process-diagram-module'; import type { ContainerConfiguration } from '@eclipse-glsp/client'; -import { Container } from 'inversify'; +import { createGlspClientModule } from '@hydranium/glsp-client'; +import { Container, ContainerModule } from 'inversify'; // LAST, and that is what makes it work: esbuild emits the bundled stylesheet in // import order, so these rules land after `@eclipse-glsp/client`'s and win on // equal specificity. Moving either up would silently lose the sizing chain and @@ -46,12 +49,19 @@ import { Container } from 'inversify'; import '@hydranium/example-order-flow-client/style/diagram.css'; import './diagram.css'; +/** The framework's client classes over the webview's console, which is the + * only sink a webview has; no logger is passed, so the binder's default is it. */ +const hydraniumModule = new ContainerModule((bind, unbind, isBound, rebind) => { + createGlspClientModule({ bind, unbind, isBound, rebind }); +}); + class OrderFlowDiagramStarter extends GLSPStarter { /** * Build the `.process` diagram container. * * The host's modules arrive in `containerConfiguration` — the diagram options - * module the starter minted plus VS Code's own feature set — and the diagram + * module the starter minted plus VS Code's own feature set — the framework's + * module follows them, since it replaces tokens they bind, and the diagram * definition is appended LAST by * {@link initializeOrderFlowProcessDiagramContainer}. That order is not a * preference: sprotty's model and view registries throw on a duplicate key, @@ -59,7 +69,7 @@ class OrderFlowDiagramStarter extends GLSPStarter { * second, and the diagram definition has to be the one holding the keys. */ createContainer(...containerConfiguration: ContainerConfiguration): Container { - return initializeOrderFlowProcessDiagramContainer(new Container(), ...containerConfiguration); + return initializeOrderFlowProcessDiagramContainer(new Container(), ...containerConfiguration, hydraniumModule); } } diff --git a/oxlint.config.cjs b/oxlint.config.cjs index e47571d6f..27fab7db8 100644 --- a/oxlint.config.cjs +++ b/oxlint.config.cjs @@ -356,6 +356,7 @@ const policyOverrides = [ 'packages/langium/src/**/*.ts', 'packages/conformance/src/**/*.ts', 'packages/client-browser/src/**/*.ts', + 'packages/glsp-client/src/**/*.ts', 'packages/client-theia/src/**/*.ts', 'packages/glsp-client-theia/src/**/*.ts', 'packages/data-client-theia/src/**/*.ts' diff --git a/package-lock.json b/package-lock.json index 7d3a6ac17..143bcd633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,7 @@ "@hydranium/data-server": "^1.0.0-next", "@hydranium/example-order-flow-client": "0.0.0", "@hydranium/example-order-flow-server": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/glsp-server": "^1.0.0-next", "@hydranium/langium": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", @@ -177,6 +178,7 @@ "@hydranium/client-theia": "^1.0.0-next", "@hydranium/data-client-theia": "^1.0.0-next", "@hydranium/example-order-flow-client": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/glsp-client-theia": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.72.0", @@ -252,6 +254,7 @@ "@hydranium/example-order-flow-client": "0.0.0", "@hydranium/example-order-flow-server": "0.0.0", "@hydranium/example-order-flow-vscode-servers": "0.0.0", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "inversify": "^6.1.3", "vscode-jsonrpc": "9.0.0", @@ -2320,6 +2323,10 @@ "resolved": "examples/order-flow/vscode-servers", "link": true }, + "node_modules/@hydranium/glsp-client": { + "resolved": "packages/glsp-client", + "link": true + }, "node_modules/@hydranium/glsp-client-theia": { "resolved": "packages/glsp-client-theia", "link": true @@ -22816,6 +22823,30 @@ "vscode-languageserver-textdocument": "^1.0.12" } }, + "packages/glsp-client": { + "name": "@hydranium/glsp-client", + "version": "1.0.0-next", + "license": "MIT", + "devDependencies": { + "@eclipse-glsp/client": "2.7.0", + "@hydranium/protocol": "1.0.0-next", + "@types/node": "^22.0.0", + "inversify": "6.2.2", + "reflect-metadata": "0.2.2", + "rimraf": "^5.0.0", + "snabbdom": "3.5.1", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22.13" + }, + "peerDependencies": { + "@eclipse-glsp/client": "^2.6.0", + "@hydranium/protocol": "^1.0.0-next", + "inversify": "^6.0.0", + "snabbdom": "^3.5.1" + } + }, "packages/glsp-client-theia": { "name": "@hydranium/glsp-client-theia", "version": "1.0.0-next", @@ -22826,6 +22857,7 @@ "@eclipse-glsp/theia-integration": "2.7.0", "@hydranium/client-theia": "1.0.0-next", "@hydranium/core": "1.0.0-next", + "@hydranium/glsp-client": "1.0.0-next", "@hydranium/glsp-server": "1.0.0-next", "@hydranium/protocol": "1.0.0-next", "@theia/core": "^1.70.0", @@ -22846,6 +22878,7 @@ "@eclipse-glsp/client": "^2.6.0", "@eclipse-glsp/theia-integration": "^2.6.0", "@hydranium/client-theia": "^1.0.0-next", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.70.0", "@theia/process": "^1.70.0", diff --git a/packages/glsp-client-theia/README.md b/packages/glsp-client-theia/README.md index 80a0779fc..86f318cba 100644 --- a/packages/glsp-client-theia/README.md +++ b/packages/glsp-client-theia/README.md @@ -3,8 +3,8 @@ The Theia integration for the [Hydranium](https://github.com/eclipse-emfcloud/hydranium) GLSP head, the companion of `@hydranium/glsp-server`. Install it to mount a Hydranium diagram -in a Theia application. It builds on `@eclipse-glsp/theia-integration` and -`@hydranium/client-theia`. +in a Theia application. It builds on `@eclipse-glsp/theia-integration`, +`@hydranium/glsp-client` and `@hydranium/client-theia`. ## What it gives you @@ -30,6 +30,7 @@ npm install @hydranium/glsp-client-theia | `@eclipse-glsp/client` | `^2.6.0` | | `@eclipse-glsp/theia-integration` | `^2.6.0` | | `@hydranium/client-theia` | `^1.0.0-next` | +| `@hydranium/glsp-client` | `^1.0.0-next` | | `@hydranium/protocol` | `^1.0.0-next` | | `@theia/core` | `^1.70.0` | | `@theia/process` | `^1.70.0` | @@ -52,8 +53,10 @@ them, one frontend and backend pair per diagram. `AbstractHydraniumGlspDiagramConfiguration` and calls `createGlspClientTheiaModule` from its `configureContainer`, not from the frontend module: called earlier, GLSP's own bindings silently replace its - rebinds. Set `propagateMarkersToProblemsView` to `false` when the LSP head - already reports the same problems. + rebinds. It binds `@hydranium/glsp-client`'s classes over the Output + channel, so you call nothing from that package yourself. Set + `propagateMarkersToProblemsView` to `false` when the LSP head already + reports the same problems. - One frontend module calls `bindEditorDiskSync` from `@hydranium/client-theia`, since a diagram save writes a file an editor can have open. @@ -73,7 +76,6 @@ For the whole setup, see *Host in Theia* in | `.` | The same as `./browser` | Theia frontend | | `./browser` | The frontend module, configuration and manager bases | Theia frontend | | `./node` | The connection handler and its backend module | Theia backend | -| `./testing` | A binding recorder for testing your modules | browser-neutral | | `./style/diagram-loading.css` | The loading overlay's stylesheet, imported for you | browser | The subpaths need a TypeScript `moduleResolution` that reads `exports` diff --git a/packages/glsp-client-theia/package.json b/packages/glsp-client-theia/package.json index 080e81ac3..406226345 100644 --- a/packages/glsp-client-theia/package.json +++ b/packages/glsp-client-theia/package.json @@ -1,7 +1,7 @@ { "name": "@hydranium/glsp-client-theia", "version": "1.0.0-next", - "description": "Theia-coupled client primitives for GLSP heads built on @hydranium/glsp-server. Provides the action dispatcher, channel logger, and connection-handler bases that Theia-based adopters extend; companion of @hydranium/glsp-server.", + "description": "Theia-coupled client primitives for GLSP heads built on @hydranium/glsp-server. Provides the diagram widget, manager, client contribution and connection-handler bases that Theia-based adopters extend, and binds @hydranium/glsp-client's host-neutral classes through the Output channel; companion of @hydranium/glsp-server.", "keywords": [ "diagram", "glsp", @@ -48,10 +48,6 @@ "types": "./lib/node/index.d.ts", "default": "./lib/node/index.js" }, - "./testing": { - "types": "./lib/testing/index.d.ts", - "default": "./lib/testing/index.js" - }, "./style/diagram-loading.css": "./style/diagram-loading.css" }, "publishConfig": { @@ -72,6 +68,7 @@ "@eclipse-glsp/theia-integration": "2.7.0", "@hydranium/client-theia": "1.0.0-next", "@hydranium/core": "1.0.0-next", + "@hydranium/glsp-client": "1.0.0-next", "@hydranium/glsp-server": "1.0.0-next", "@hydranium/protocol": "1.0.0-next", "@theia/core": "^1.70.0", @@ -89,6 +86,7 @@ "@eclipse-glsp/client": "^2.6.0", "@eclipse-glsp/theia-integration": "^2.6.0", "@hydranium/client-theia": "^1.0.0-next", + "@hydranium/glsp-client": "^1.0.0-next", "@hydranium/protocol": "^1.0.0-next", "@theia/core": "^1.70.0", "@theia/process": "^1.70.0", diff --git a/packages/glsp-client-theia/src/browser/client-contribution.ts b/packages/glsp-client-theia/src/browser/client-contribution.ts index cc47af9c1..961861c05 100644 --- a/packages/glsp-client-theia/src/browser/client-contribution.ts +++ b/packages/glsp-client-theia/src/browser/client-contribution.ts @@ -12,6 +12,7 @@ import { BaseGLSPClientContribution } from '@eclipse-glsp/theia-integration'; import { createChannelConnection, GLSPContribution } from '@eclipse-glsp/theia-integration/lib/common'; import { ChannelLogger, ConnectionReporter, type ConnectionTarget } from '@hydranium/client-theia/browser'; import { Clock } from '@hydranium/client-theia/common'; +import { HydraniumGlspClient } from '@hydranium/glsp-client'; import { sendByMethodName, describeError, type Stopwatch, SystemClock } from '@hydranium/protocol'; import { type Channel, Disposable, Emitter, Event, nls } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -20,7 +21,6 @@ import { WorkspaceService } from '@theia/workspace/lib/browser'; // GLSP's channel connection uses the top-level `vscode-jsonrpc`'s root entry, which // installs no runtime layer; without the browser entry, no message is received. import 'vscode-jsonrpc/browser'; -import { HydraniumGlspClient } from './glsp-client'; export const DEFAULT_GLSP_CLIENT_STARTUP_TIMEOUT_MS = 30_000; diff --git a/packages/glsp-client-theia/src/browser/diagram-widget.ts b/packages/glsp-client-theia/src/browser/diagram-widget.ts index fc6e9a2a5..eeef419e2 100644 --- a/packages/glsp-client-theia/src/browser/diagram-widget.ts +++ b/packages/glsp-client-theia/src/browser/diagram-widget.ts @@ -10,12 +10,12 @@ import { type Args, DiagramLoader, EditorContextService, GLSPActionDispatcher, SetViewportAction } from '@eclipse-glsp/client'; import { RESUME_TOKEN_ARG } from '@hydranium/protocol'; import { GLSPDiagramWidget, type GLSPDiagramWidgetOptions } from '@eclipse-glsp/theia-integration'; +import { type DiagramLoadOutcome, HydraniumDiagramLoader } from '@hydranium/glsp-client'; // Type-only: the `@theia/core/lib/browser` barrel touches DOM globals at module // load, which the node-environment unit tests cannot provide. import { type Message } from '@theia/core/lib/browser'; import { Emitter, type Event, nls } from '@theia/core'; import { type Container, inject, injectable } from '@theia/core/shared/inversify'; -import { type DiagramLoadOutcome, HydraniumDiagramLoader } from './diagram-loader'; import { HydraniumGlspSaveable } from './glsp-saveable'; import { WindowSessionService } from './window-session'; // Shipped by this package rather than left to adopters: an overlay whose diff --git a/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts b/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts index c14169f2a..a00ac310f 100644 --- a/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts +++ b/packages/glsp-client-theia/src/browser/glsp-client-theia-module.ts @@ -7,13 +7,19 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -import { bindChannelLogger, type ChannelLoggerOptions } from '@hydranium/client-theia/browser'; -import { type BindingContext, DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater, StatusOverlay } from '@eclipse-glsp/client'; -import { HydraniumGlspActionDispatcher } from './action-dispatcher'; -import { HydraniumDiagramLoader } from './diagram-loader'; -import { HydraniumStatusOverlay } from './status-overlay'; +import { type BindingContext, DiagramLoader } from '@eclipse-glsp/client'; +import { + bindChannelLogger, + ChannelLogger, + type ChannelLoggerOptions, + ChannelTracer, + getRequestParentName +} from '@hydranium/client-theia/browser'; +import { Clock } from '@hydranium/client-theia/common'; +import { createGlspClientModule, HydraniumClientTypes } from '@hydranium/glsp-client'; +import { SystemClock } from '@hydranium/protocol'; import { bindHydraniumGlspMessageService } from './glsp-message-service'; -import { HydraniumHiddenBoundsUpdater } from './hidden-bounds-updater'; +import { HydraniumGlspTheiaDiagramLoader } from './theia-diagram-loader'; /** Options for `createGlspClientTheiaModule`. The adopter supplies the channel * logger configuration ({@link ChannelLoggerOptions} — a `channelName`, and @@ -26,10 +32,17 @@ export interface GlspClientTheiaModuleOptions { } /** - * Composes the standard framework client bindings: the cross-head Output-channel - * logger, the instrumented action dispatcher, the failure-reporting diagram - * loader, the instrumented hidden-bounds updater, and the message service that - * drops the duplicate model-loading notification. + * Composes the standard framework client bindings for a Theia frontend: the + * cross-head Output-channel logger, the host-neutral classes + * `createGlspClientModule` binds, fed from that channel, the loader that renders + * its failure through Theia's catalogue, and the message service that drops + * the duplicate model-loading notification. + * + * The neutral tokens are bound BEFORE the neutral binder runs, so it binds no + * console defaults. The logger binding tags each line with the requesting + * class, as `bindChannelLogger`'s own does; a plain `toService` alias would + * start a fresh request and lose the tag. The clock is resolved lazily, so a + * clock an adopter module binds after this one is still the one used. * * Takes the whole {@link BindingContext} rather than `(bind, rebind)`: the * message-service rebind has to know whether GLSP's `theiaNotificationModule` @@ -47,10 +60,16 @@ export interface GlspClientTheiaModuleOptions { export function createGlspClientTheiaModule(context: BindingContext, options: GlspClientTheiaModuleOptions): void { const { bind, isBound, rebind } = context; bindChannelLogger(bind, options.channelLogger); - bind(HydraniumGlspActionDispatcher).toSelf().inSingletonScope(); - rebind(GLSPActionDispatcher).toService(HydraniumGlspActionDispatcher); - rebind(DiagramLoader).to(HydraniumDiagramLoader).inSingletonScope(); - rebind(GLSPHiddenBoundsUpdater).to(HydraniumHiddenBoundsUpdater).inSingletonScope(); - rebind(StatusOverlay).to(HydraniumStatusOverlay).inSingletonScope(); + bind(HydraniumClientTypes.Logger).toDynamicValue(ctx => { + const base = ctx.container.get(ChannelLogger); + const parentName = getRequestParentName(ctx); + return parentName ? base.for(parentName) : base; + }); + bind(HydraniumClientTypes.Tracer).toService(ChannelTracer); + bind(HydraniumClientTypes.Clock) + .toDynamicValue(ctx => (ctx.container.isBound(Clock) ? ctx.container.get(Clock) : new SystemClock())) + .inSingletonScope(); + createGlspClientModule(context); + rebind(DiagramLoader).to(HydraniumGlspTheiaDiagramLoader).inSingletonScope(); bindHydraniumGlspMessageService(bind, isBound, rebind); } diff --git a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts index eeaa53be3..52dd6c359 100644 --- a/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts +++ b/packages/glsp-client-theia/src/browser/glsp-diagram-manager.ts @@ -19,8 +19,8 @@ import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; import { type Disposable, DisposableCollection } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { textHash } from '@hydranium/protocol'; +import { HydraniumDiagramLoader } from '@hydranium/glsp-client'; import { HydraniumGlspClientContribution } from './client-contribution'; -import { HydraniumDiagramLoader } from './diagram-loader'; import { HydraniumGlspDiagramWidget } from './diagram-widget'; import { WindowSessionService } from './window-session'; diff --git a/packages/glsp-client-theia/src/browser/index.ts b/packages/glsp-client-theia/src/browser/index.ts index 630de26d4..beda1e626 100644 --- a/packages/glsp-client-theia/src/browser/index.ts +++ b/packages/glsp-client-theia/src/browser/index.ts @@ -7,22 +7,20 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// Browser-side framework primitives for GLSP-on-Theia adopters. -// The cross-head Output-channel logger lives in `@hydranium/client-theia/browser`. -export * from './action-dispatcher'; +// Browser-side framework primitives for GLSP-on-Theia adopters. The +// host-neutral classes (the dispatcher, loader, updater, overlay and client) +// are `@hydranium/glsp-client`'s; the cross-head Output-channel logger lives +// in `@hydranium/client-theia/browser`. export * from './client-contribution'; -export * from './diagram-loader'; export * from './diagram-only-marker-manager'; export * from './diagram-widget'; -export * from './glsp-client'; export * from './glsp-client-theia-module'; export * from './glsp-diagram-manager'; export * from './glsp-message-service'; export * from './glsp-saveable'; export * from './glsp-theia-frontend-module'; -export * from './hidden-bounds-updater'; export * from './hydranium-glsp-diagram-configuration'; -export * from './status-overlay'; +export * from './theia-diagram-loader'; export * from './window-session'; // Upstream's root entry is its browser tier and re-exports no `lib/common`, so the diff --git a/packages/glsp-client-theia/src/browser/theia-diagram-loader.ts b/packages/glsp-client-theia/src/browser/theia-diagram-loader.ts new file mode 100644 index 000000000..de7cf14a3 --- /dev/null +++ b/packages/glsp-client-theia/src/browser/theia-diagram-loader.ts @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { HydraniumDiagramLoader } from '@hydranium/glsp-client'; +import { nls } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; + +/** + * The framework loader rendering its failure through Theia's catalogue, which + * knows the reading user's locale where the neutral loader does not. + * + * The key and the English are inline literals, because `theia nls-extract` + * drops a key it cannot resolve. The English must match + * `DIAGRAM_LOAD_FAILED`'s text in `@hydranium/glsp-client`; a test holds them + * equal. + * + * Bound by `createGlspClientTheiaModule` via `rebind(DiagramLoader)`, after + * `createGlspClientModule` bound the neutral loader. + */ +@injectable() +export class HydraniumGlspTheiaDiagramLoader extends HydraniumDiagramLoader { + override loadFailureLabel(err: unknown): string { + return nls.localize('hydranium/glsp-client-theia/diagram-load-failed', 'Diagram failed to load: {0}', this.formatError(err)); + } +} diff --git a/packages/glsp-client-theia/test/browser/diagram-widget.test.ts b/packages/glsp-client-theia/test/browser/diagram-widget.test.ts index 0b00e137b..5b37bd51b 100644 --- a/packages/glsp-client-theia/test/browser/diagram-widget.test.ts +++ b/packages/glsp-client-theia/test/browser/diagram-widget.test.ts @@ -75,7 +75,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type Message } from '@theia/core/lib/browser'; import { RESUME_TOKEN_ARG } from '@hydranium/protocol'; import { DIAGRAM_LOADING_CLASS, DIAGRAM_LOADING_FAILED_CLASS, HydraniumGlspDiagramWidget } from '../../src/browser/diagram-widget'; -import { type DiagramLoadOutcome, HydraniumDiagramLoader } from '../../src/browser/diagram-loader'; +import { type DiagramLoadOutcome, HydraniumDiagramLoader } from '@hydranium/glsp-client'; import { HydraniumGlspSaveable } from '../../src/browser/glsp-saveable'; import { DefaultWindowSessionService, type WindowSessionService } from '../../src/browser/window-session'; diff --git a/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts b/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts index 5fc6807c0..d82906051 100644 --- a/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-client-theia-module.test.ts @@ -12,11 +12,16 @@ // `@theia/monaco` → `@lumino/widgets` (DOM globals at module load). Mocking the // cross-head logger surface also scopes these cases to what this module owns: // that it forwards to `bindChannelLogger`, not what that function then binds. +// `getRequestParentName` is restated rather than loaded, since the real one +// sits in the same DOM-bound module. const { bindChannelLoggerMock } = vi.hoisted(() => ({ bindChannelLoggerMock: vi.fn() })); vi.mock('@hydranium/client-theia/browser', () => ({ bindChannelLogger: bindChannelLoggerMock, ChannelLogger: class ChannelLogger {}, - ChannelTracer: Symbol('ChannelTracer') + ChannelTracer: Symbol('ChannelTracer'), + getRequestParentName: (context: interfaces.Context) => + (context.currentRequest.parentRequest?.bindings[0] as { implementationType?: { name?: string } } | undefined)?.implementationType + ?.name })); // Importing the real `@eclipse-glsp/theia-integration` resolves back into // `@eclipse-glsp/client`'s TypeScript *sources* via its exports map, which the node @@ -26,19 +31,82 @@ vi.mock('@eclipse-glsp/theia-integration', () => ({ TheiaGLSPMessageService: class TheiaGLSPMessageService {} })); -import { DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater, StatusOverlay } from '@eclipse-glsp/client'; +import 'reflect-metadata'; +import { + createDiagramOptionsModule, + DiagramLoader, + GLSPActionDispatcher, + GLSPHiddenBoundsUpdater, + initializeDiagramContainer, + StatusOverlay, + TYPES +} from '@eclipse-glsp/client'; import { TheiaGLSPMessageService } from '@eclipse-glsp/theia-integration'; +import { ChannelLogger, ChannelTracer } from '@hydranium/client-theia/browser'; +import { Clock } from '@hydranium/client-theia/common'; +import { + HydraniumClientTypes, + HydraniumGlspActionDispatcher, + HydraniumHiddenBoundsUpdater, + SprottyLoggerAdapter +} from '@hydranium/glsp-client'; +import { type BindRecorder, type BindRecorderOptions, makeBindRecorder } from '@hydranium/glsp-client/testing'; +import { SystemClock } from '@hydranium/protocol'; +import { makeFakeClock } from '@hydranium/protocol/testing'; +import { Container, ContainerModule, inject, injectable, type interfaces } from '@theia/core/shared/inversify'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HydraniumGlspActionDispatcher } from '../../src/browser/action-dispatcher'; -import { HydraniumDiagramLoader } from '../../src/browser/diagram-loader'; import { createGlspClientTheiaModule, type GlspClientTheiaModuleOptions } from '../../src/browser/glsp-client-theia-module'; import { HydraniumGlspMessageService } from '../../src/browser/glsp-message-service'; -import { HydraniumHiddenBoundsUpdater } from '../../src/browser/hidden-bounds-updater'; -import { HydraniumStatusOverlay } from '../../src/browser/status-overlay'; -import { type BindRecorder, type BindRecorderOptions, makeBindRecorder } from '../../src/testing/bind-recorder'; +import { HydraniumGlspTheiaDiagramLoader } from '../../src/browser/theia-diagram-loader'; const CHANNEL = { channelName: 'Test' }; +/** A channel logger double whose derived loggers remember their component. */ +class TaggingLogger { + constructor(readonly component?: string) {} + for(component: string): TaggingLogger { + return new TaggingLogger(component); + } +} + +/** A class injecting the neutral logger token, so the per-class tag has a requester. */ +@injectable() +class LoggerConsumer { + @inject(HydraniumClientTypes.Logger) readonly logger!: TaggingLogger; +} + +/** + * A real container with GLSP's default tokens and the channel logger bound as + * their modules would have, so the token bindings are checked by resolving + * them. `bindChannelLogger` is mocked away, so its two bindings are stood in. + */ +function makeContainer(): Container { + const container = new Container(); + const glspTokens: interfaces.ServiceIdentifier[] = [ + GLSPActionDispatcher, + DiagramLoader, + GLSPHiddenBoundsUpdater, + StatusOverlay, + TYPES.ILogger, + TheiaGLSPMessageService + ]; + for (const token of glspTokens) { + container.bind(token).toConstantValue({}); + } + container.bind(ChannelLogger).toConstantValue(new TaggingLogger() as unknown as ChannelLogger); + container.bind(ChannelTracer).toConstantValue({ time: () => undefined }); + createGlspClientTheiaModule( + { + bind: container.bind.bind(container), + unbind: container.unbind.bind(container), + isBound: container.isBound.bind(container), + rebind: container.rebind.bind(container) + }, + { channelLogger: CHANNEL } + ); + return container; +} + describe('createGlspClientTheiaModule', () => { beforeEach(() => { bindChannelLoggerMock.mockClear(); @@ -58,27 +126,20 @@ describe('createGlspClientTheiaModule', () => { expect(bindChannelLoggerMock).toHaveBeenCalledWith(recorder.bind, CHANNEL); }); - it('binds HydraniumGlspActionDispatcher as a self-bound singleton', () => { - expect(record().find('bind', HydraniumGlspActionDispatcher)?.chain).toEqual(['toSelf()', 'inSingletonScope()']); - }); - - it('rebinds GLSPActionDispatcher to point at HydraniumGlspActionDispatcher', () => { - expect(record().find('rebind', GLSPActionDispatcher)?.chain).toEqual([`toService(<${HydraniumGlspActionDispatcher.name}>)`]); - }); - - it('rebinds DiagramLoader to the framework loader', () => { - expect(record().find('rebind', DiagramLoader)?.chain).toEqual([`to(<${HydraniumDiagramLoader.name}>)`, 'inSingletonScope()']); - }); - - it('rebinds GLSPHiddenBoundsUpdater to the instrumented updater', () => { + it('binds the neutral classes through createGlspClientModule', () => { expect(record().find('rebind', GLSPHiddenBoundsUpdater)?.chain).toEqual([ `to(<${HydraniumHiddenBoundsUpdater.name}>)`, 'inSingletonScope()' ]); }); - it('rebinds StatusOverlay to the one that keeps its element on the page', () => { - expect(record().find('rebind', StatusOverlay)?.chain).toEqual([`to(<${HydraniumStatusOverlay.name}>)`, 'inSingletonScope()']); + it('rebinds DiagramLoader last to the loader that renders through the catalogue', () => { + const rebinds = record().captured.filter(entry => entry.kind === 'rebind' && entry.token === DiagramLoader); + expect(rebinds.at(-1)?.chain).toEqual([`to(<${HydraniumGlspTheiaDiagramLoader.name}>)`, 'inSingletonScope()']); + }); + + it('binds the neutral tracer token to the channel tracer', () => { + expect(record().find('bind', HydraniumClientTypes.Tracer)?.chain).toEqual([`toService(${ChannelTracer.toString()})`]); }); it('rebinds the Theia message service so the duplicate model-loading toast is dropped', () => { @@ -98,4 +159,49 @@ describe('createGlspClientTheiaModule', () => { 'inSingletonScope()' ]); }); + + describe('resolved in a container', () => { + it('gives the neutral logger token the channel logger, tagged with the requesting class', () => { + const container = makeContainer(); + container.bind(LoggerConsumer).toSelf(); + expect(container.get(LoggerConsumer).logger.component).toBe(LoggerConsumer.name); + }); + + it('gives the neutral logger token the untagged channel logger when nothing requests it', () => { + expect(makeContainer().get(HydraniumClientTypes.Logger).component).toBeUndefined(); + }); + + it('binds no console logger of its own, since the channel is bound first', () => { + expect(makeContainer().get(HydraniumClientTypes.Logger)).toBeInstanceOf(TaggingLogger); + }); + + it('gives the neutral clock token a system clock when the Theia clock is unbound', () => { + expect(makeContainer().get(HydraniumClientTypes.Clock)).toBeInstanceOf(SystemClock); + }); + + it('gives the neutral clock token the Theia clock, even one bound after this module', () => { + const container = makeContainer(); + const clock = makeFakeClock(); + container.bind(Clock).toConstantValue(clock); + expect(container.get(HydraniumClientTypes.Clock)).toBe(clock); + }); + + it('resolves the real classes in a GLSP container, the loader being the Theia one', () => { + // GLSP's own default modules, so the `@inject` tokens are exercised by + // resolution rather than asserted as recorder strings. + const theiaModule = new ContainerModule((bind, unbind, isBound, rebind) => { + bind(ChannelLogger).toConstantValue(new TaggingLogger() as unknown as ChannelLogger); + bind(ChannelTracer).toConstantValue({ time: () => undefined }); + createGlspClientTheiaModule({ bind, unbind, isBound, rebind }, { channelLogger: CHANNEL }); + }); + const container = initializeDiagramContainer( + new Container(), + createDiagramOptionsModule({ clientId: 'one', diagramType: 'diagram-one', glspClientProvider: async () => ({}) as never }), + theiaModule + ); + expect(container.get(DiagramLoader)).toBeInstanceOf(HydraniumGlspTheiaDiagramLoader); + expect(container.get(GLSPActionDispatcher)).toBeInstanceOf(HydraniumGlspActionDispatcher); + expect(container.get(TYPES.ILogger)).toBeInstanceOf(SprottyLoggerAdapter); + }); + }); }); diff --git a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts index 5c71e9bcc..f8abb93c8 100644 --- a/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-diagram-manager.test.ts @@ -10,10 +10,10 @@ import { DiagramLoader } from '@eclipse-glsp/client'; import { type GLSPDiagramWidget } from '@eclipse-glsp/theia-integration/lib/browser'; import { type GLSPDiagramLanguage } from '@eclipse-glsp/theia-integration/lib/common'; +import { HydraniumDiagramLoader } from '@hydranium/glsp-client'; import { type WidgetOpenerOptions } from '@theia/core/lib/browser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HydraniumGlspClientContribution } from '../../src/browser/client-contribution.js'; -import { HydraniumDiagramLoader } from '../../src/browser/diagram-loader.js'; import { HydraniumGlspDiagramWidget } from '../../src/browser/diagram-widget.js'; import { AbstractHydraniumGlspDiagramManager } from '../../src/browser/glsp-diagram-manager.js'; import { DefaultWindowSessionService, type WindowSessionService } from '../../src/browser/window-session.js'; diff --git a/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts b/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts index 52f721ae2..a73665304 100644 --- a/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts +++ b/packages/glsp-client-theia/test/browser/glsp-theia-frontend-module.test.ts @@ -64,7 +64,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type interfaces } from '@theia/core/shared/inversify'; import { HydraniumGlspDiagramWidget } from '../../src/browser/diagram-widget'; import { AbstractHydraniumGlspTheiaFrontendModule, SkipClientContribution } from '../../src/browser/glsp-theia-frontend-module'; -import { type BindRecorder, makeBindRecorder } from '../../src/testing/bind-recorder'; +import { type BindRecorder, makeBindRecorder } from '@hydranium/glsp-client/testing'; const LANGUAGE: GLSPDiagramLanguage = { contributionId: 'lang-one', diff --git a/packages/glsp-client-theia/test/browser/theia-diagram-loader.test.ts b/packages/glsp-client-theia/test/browser/theia-diagram-loader.test.ts new file mode 100644 index 000000000..82c7b2b91 --- /dev/null +++ b/packages/glsp-client-theia/test/browser/theia-diagram-loader.test.ts @@ -0,0 +1,39 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { DIAGRAM_LOAD_FAILED } from '@hydranium/glsp-client/messages'; +import { nls } from '@theia/core'; +import { describe, expect, it, vi } from 'vitest'; +import { HydraniumGlspTheiaDiagramLoader } from '../../src/browser/theia-diagram-loader'; + +describe('HydraniumGlspTheiaDiagramLoader', () => { + /** + * The KEY, not the sentence: `nls.localize` returns the English unchanged + * with no catalogue loaded, so a sentence assertion passes with the key + * renamed or the call removed. The parameter is asserted too, because `{0}` + * is what makes this one catalogue entry rather than one per error text. + */ + it('renders the failure through its catalogue key, with the detail as a parameter', () => { + const localize = vi.spyOn(nls, 'localize'); + new HydraniumGlspTheiaDiagramLoader().loadFailureLabel(new Error('connection refused')); + expect(localize).toHaveBeenCalledWith( + 'hydranium/glsp-client-theia/diagram-load-failed', + 'Diagram failed to load: {0}', + 'connection refused' + ); + localize.mockRestore(); + }); + + /** The English is spelled twice, once per package; a reword of one must fail here. */ + it('spells the same English as the neutral declaration', () => { + expect(new HydraniumGlspTheiaDiagramLoader().loadFailureLabel(new Error('connection refused'))).toBe( + DIAGRAM_LOAD_FAILED.format({ reason: 'connection refused' }) + ); + }); +}); diff --git a/packages/glsp-client-theia/tsconfig.json b/packages/glsp-client-theia/tsconfig.json index 1b1449a6b..cb5bbb0ab 100644 --- a/packages/glsp-client-theia/tsconfig.json +++ b/packages/glsp-client-theia/tsconfig.json @@ -9,5 +9,9 @@ "noUncheckedSideEffectImports": false }, "include": ["src"], - "references": [{ "path": "../protocol" }, { "path": "../client-theia" }] + "references": [ + { "path": "../protocol" }, + { "path": "../glsp-client" }, + { "path": "../client-theia" } + ] } diff --git a/packages/glsp-client-theia/vitest.config.ts b/packages/glsp-client-theia/vitest.config.ts index ffaf75c1f..b6e01d306 100644 --- a/packages/glsp-client-theia/vitest.config.ts +++ b/packages/glsp-client-theia/vitest.config.ts @@ -7,6 +7,7 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ +import { resolve } from 'node:path'; import { mergeConfig } from 'vitest/config'; import { definePackageVitestConfig } from '../../vitest.shared'; @@ -49,7 +50,19 @@ import { definePackageVitestConfig } from '../../vitest.shared'; // here has: the shared base runs every suite under `environment: 'node'`. // // So a test needing one of this package's exports MOCKS the module. +// +// `@hydranium/glsp-client` is aliased to its SOURCES. Its built `lib` is +// CommonJS, so its `require` of `@eclipse-glsp/client` is Node's and reaches +// the stylesheet above the prebundle; the TypeScript sources import it as ESM, +// which the optimizer serves. Its own tsconfig supplies the decorator flags. +const glspClientSrc = resolve(__dirname, '../glsp-client/src'); export default mergeConfig(definePackageVitestConfig('glsp-client-theia'), { + resolve: { + alias: [ + { find: /^@hydranium\/glsp-client$/, replacement: resolve(glspClientSrc, 'index.ts') }, + { find: /^@hydranium\/glsp-client\/(messages|testing)$/, replacement: resolve(glspClientSrc, '$1/index.ts') } + ] + }, test: { deps: { optimizer: { diff --git a/packages/glsp-client/LICENSE b/packages/glsp-client/LICENSE new file mode 100644 index 000000000..45f4318ab --- /dev/null +++ b/packages/glsp-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CrossBreeze, EclipseSource and others. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/glsp-client/README.md b/packages/glsp-client/README.md new file mode 100644 index 000000000..0c8e35bf3 --- /dev/null +++ b/packages/glsp-client/README.md @@ -0,0 +1,89 @@ +# `@hydranium/glsp-client` + +The host-neutral client classes for the +[Hydranium](https://github.com/eclipse-emfcloud/hydranium) GLSP head, the +companion of `@hydranium/glsp-server`. Install it to mount a Hydranium diagram +in any GLSP host: a Theia frontend through `@hydranium/glsp-client-theia`, a +VS Code webview, or a plain browser page. It builds on `@eclipse-glsp/client`. + +## What it gives you + +- Load failures reported through your logger and shown on the diagram's + status overlay, instead of vanishing as an uncaught rejection. +- Action traffic and bounds measurements in your log, so a slow diagram names + its cost. +- Edge routes repaired before they go to the server while sprotty has not + measured the obstacles yet. +- sprotty's own errors, such as an element type with no view, in your log + rather than dropped. +- One binder that mounts all of it in a diagram container. + +## Install + +```bash +npm install @hydranium/glsp-client +``` + +| Peer | Range | +| ---------------------- | ------------- | +| `@eclipse-glsp/client` | `^2.6.0` | +| `@hydranium/protocol` | `^1.0.0-next` | +| `inversify` | `^6.0.0` | +| `snabbdom` | `^3.5.1` | + +## Wiring + +Call `createGlspClientModule` from a container module your host adds after +GLSP's default modules, since it replaces tokens those modules bind: + + + +```ts +import { createGlspClientModule } from '@hydranium/glsp-client'; +import { ContainerModule } from 'inversify'; + +const hostModule = new ContainerModule((bind, unbind, isBound, rebind) => { + createGlspClientModule({ bind, unbind, isBound, rebind }, { logger }); +}); +``` + +Pass your `Logger` from `@hydranium/protocol`, which replaces any binding on +`HydraniumClientTypes.Logger`, or bind that token yourself and pass none; with +neither, a `ConsoleLogger` is bound. +Bind `HydraniumClientTypes.Clock` to drive time in a test. To render the load +failure in the user's language, subclass `HydraniumDiagramLoader`, override +`loadFailureLabel` and rebind `DiagramLoader` after the call. In Theia, call +`createGlspClientTheiaModule` from `@hydranium/glsp-client-theia` instead, +which does all of this with the Output channel. + +For a browser page, see *Host in a browser* in +[Adopting Hydranium](https://github.com/eclipse-emfcloud/hydranium/blob/main/docs/ADOPTING.md). + +## Entry points + +| Subpath | Use it for | Runs in | +| ------------ | ------------------------------------------------- | --------------- | +| `.` | The classes, the binder and the DI tokens | browser | +| `./messages` | The codes of the messages this package raises | browser-neutral | +| `./testing` | A binding recorder for testing your modules | browser-neutral | + +The root loads `@eclipse-glsp/client`, whose graph imports stylesheets, so +your bundler needs a CSS loader. The subpaths need a TypeScript +`moduleResolution` that reads `exports` (`NodeNext` or `Bundler`); see +*Requirements* in +[Adopting Hydranium](https://github.com/eclipse-emfcloud/hydranium/blob/main/docs/ADOPTING.md). + +## Status + +Alpha: every release is a prerelease that may break the API, so pin an exact +version. Guides and known limitations: +[Adopting Hydranium](https://github.com/eclipse-emfcloud/hydranium/blob/main/docs/ADOPTING.md). + +## License + +`MIT` — see this package's [`LICENSE`](./LICENSE), and the repository +[`NOTICE.md`](https://github.com/eclipse-emfcloud/hydranium/blob/main/NOTICE.md) +for third-party notices. diff --git a/packages/glsp-client/package.json b/packages/glsp-client/package.json new file mode 100644 index 000000000..a3ad0927a --- /dev/null +++ b/packages/glsp-client/package.json @@ -0,0 +1,81 @@ +{ + "name": "@hydranium/glsp-client", + "version": "1.0.0-next", + "description": "Host-neutral client classes for GLSP heads built on @hydranium/glsp-server: the instrumented action dispatcher, the failure-reporting diagram loader, the route-repairing hidden-bounds updater and one binder that mounts them in a Theia frontend, a VS Code webview or a plain browser page.", + "keywords": [ + "diagram", + "glsp", + "hydranium", + "langium", + "language-server", + "lsp" + ], + "homepage": "https://github.com/eclipse-emfcloud/hydranium/tree/main/packages/glsp-client", + "bugs": { + "url": "https://github.com/eclipse-emfcloud/hydranium/issues" + }, + "license": "MIT", + "author": { + "name": "Hydranium Team" + }, + "repository": { + "type": "git", + "url": "https://github.com/eclipse-emfcloud/hydranium.git", + "directory": "packages/glsp-client" + }, + "files": [ + "lib", + "src", + "!lib/**/*.tsbuildinfo" + ], + "sideEffects": false, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./messages": { + "types": "./lib/messages/index.d.ts", + "default": "./lib/messages/index.js" + }, + "./testing": { + "types": "./lib/testing/index.d.ts", + "default": "./lib/testing/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -b", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "lint": "oxlint --format stylish --config ../../oxlint.config.cjs src test --max-warnings 0", + "prepack": "node -e \"const m=require('./package.json'),fs=require('node:fs');const missing=[m.main,...Object.values(m.bin||{})].filter(entry=>entry&&!fs.existsSync(entry));if(missing.length){console.error('prepack '+m.name+': not built ('+missing.join(', ')+' missing). Run the build before packing: a files entry that matches nothing is skipped silently, so the tarball would ship src only.');process.exit(1);}\"", + "test": "npm run typecheck:test && vitest run", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", + "watch": "tsc -b -w --preserveWatchOutput" + }, + "devDependencies": { + "@eclipse-glsp/client": "2.7.0", + "@hydranium/protocol": "1.0.0-next", + "@types/node": "^22.0.0", + "inversify": "6.2.2", + "reflect-metadata": "0.2.2", + "rimraf": "^5.0.0", + "snabbdom": "3.5.1", + "typescript": "^7.0.2" + }, + "peerDependencies": { + "@eclipse-glsp/client": "^2.6.0", + "@hydranium/protocol": "^1.0.0-next", + "inversify": "^6.0.0", + "snabbdom": "^3.5.1" + }, + "engines": { + "node": ">=22.13" + }, + "//peerDependencies": "`inversify` is imported plainly, as `@eclipse-glsp/client` imports it, and peered so the classes here decorate with the same copy the diagram container resolves; `@theia/core/shared/inversify` re-exports that copy, so a Theia frontend satisfies it without a second install. `snabbdom` is a peer for the `VNode` type the hidden-bounds updater reads, the same reason the Theia package peers it.", + "//prepack": "The publish guard, a `prepack` rather than a `prepare`: `prepack` runs only when a tarball is made (`npm pack`, `npm publish`) and never on install, so it cannot break the install it has no business touching. What it defends against: `files` lists `lib`, `lib` is gitignored, and a `files` entry matching nothing is skipped SILENTLY — so `npm publish` from an unbuilt tree emits a tarball of `src` and nothing else, with no error." +} diff --git a/packages/glsp-client-theia/src/browser/action-dispatcher.ts b/packages/glsp-client/src/action-dispatcher.ts similarity index 91% rename from packages/glsp-client-theia/src/browser/action-dispatcher.ts rename to packages/glsp-client/src/action-dispatcher.ts index 07b5943a7..31260bfd7 100644 --- a/packages/glsp-client-theia/src/browser/action-dispatcher.ts +++ b/packages/glsp-client/src/action-dispatcher.ts @@ -32,13 +32,12 @@ import { StatusAction, UpdateModelAction } from '@eclipse-glsp/client'; -import { ChannelLogger, ChannelTracer } from '@hydranium/client-theia/browser'; -import { Clock } from '@hydranium/client-theia/common'; -import { type Stopwatch, SystemClock, type Tracer } from '@hydranium/protocol'; -import { inject, injectable, optional, unmanaged } from '@theia/core/shared/inversify'; +import { type Clock, type Logger, type Stopwatch, SystemClock, type Tracer } from '@hydranium/protocol'; +import { inject, injectable, optional, unmanaged } from 'inversify'; +import { HydraniumClientTypes } from './types'; /** Default subset of action kinds the framework dispatcher will log: the GLSP - * traffic an observer reading the Output channel typically wants to see. All + * traffic an observer reading the log typically wants to see. All * are standard `@eclipse-glsp/client` kinds; adopters with custom action kinds * extend via `extraLoggedKinds`. */ export const HYDRANIUM_DEFAULT_LOGGED_KINDS: ReadonlySet = new Set([ @@ -81,8 +80,9 @@ export interface HydraniumGlspActionDispatcherOptions { } /** - * Wraps every dispatch so action traffic appears in the framework Output - * channel, interleaved with server log lines. Instrumenting the dispatcher + * Wraps every dispatch so action traffic appears in the host's log, the Theia + * Output channel or a page's console, interleaved with server log lines where + * the host shows both. Instrumenting the dispatcher * (rather than per-kind handlers) catches ResponseActions that bypass the * handler pipeline. * @@ -93,10 +93,10 @@ export interface HydraniumGlspActionDispatcherOptions { */ @injectable() export class HydraniumGlspActionDispatcher extends GLSPActionDispatcher { - @inject(ChannelLogger) protected readonly channel!: ChannelLogger; - @inject(ChannelTracer) protected readonly tracer!: Tracer; + @inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger; + @inject(HydraniumClientTypes.Tracer) protected readonly tracer!: Tracer; /** Times each request to its response: the diagram container's {@link Clock}, or a `SystemClock`. */ - @inject(Clock) @optional() protected readonly clock: Clock = new SystemClock(); + @inject(HydraniumClientTypes.Clock) @optional() protected readonly clock: Clock = new SystemClock(); protected readonly loggedKinds: ReadonlySet; protected readonly kindPairs: ReadonlyMap; @@ -142,7 +142,7 @@ export class HydraniumGlspActionDispatcher extends GLSPActionDispatcher { try { this.tracer.time('Model initialized', () => this.onceModelInitialized(), 'info', { logAfterMs: 0 }); } catch (err) { - this.channel.error('Failed to register model-initialized timing instrumentation', err); + this.hydraniumLogger.error('Failed to register model-initialized timing instrumentation', err); } } @@ -164,7 +164,7 @@ export class HydraniumGlspActionDispatcher extends GLSPActionDispatcher { if (timing) { parts.push(timing); } - this.channel.trace(parts.join(' ')); + this.hydraniumLogger.trace(parts.join(' ')); } protected classify(action: Action): { verb: string; direction: string } { @@ -265,7 +265,7 @@ export class HydraniumGlspActionDispatcher extends GLSPActionDispatcher { return ''; } - /** An edge check and, on the result, its verdict, so the channel shows why an edge tool refused a target. */ + /** An edge check and, on the result, its verdict, so the log shows why an edge tool refused a target. */ protected formatEdgeCheck(action: RequestCheckEdgeAction | CheckEdgeResultAction): string { const verdict = CheckEdgeResultAction.is(action) ? ` isValid=${action.isValid}` : ''; return `edgeType=${action.edgeType} source=${action.sourceElementId} target=${action.targetElementId ?? 'none'}${verdict}`; diff --git a/packages/glsp-client/src/diagram-load-failed.ts b/packages/glsp-client/src/diagram-load-failed.ts new file mode 100644 index 000000000..4d7df8f18 --- /dev/null +++ b/packages/glsp-client/src/diagram-load-failed.ts @@ -0,0 +1,22 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { defineMessage } from '@hydranium/protocol'; + +/** + * What a user reads when a diagram load fails: `reason` is the described + * error. Formatted in English by `HydraniumDiagramLoader`; a host that knows + * the reading user's locale renders the same sentence under a key of its own + * by overriding its `loadFailureLabel`, as the Theia loader does. + * + * In a module of its own, apart from the loader that raises it, so the + * `./messages` barrel loads no `@eclipse-glsp/client`: a bundle that only + * translates the code must not need GLSP's stylesheets. + */ +export const DIAGRAM_LOAD_FAILED = defineMessage('hydranium/glsp-client/diagram-load-failed', 'Diagram failed to load: {reason}'); diff --git a/packages/glsp-client-theia/src/browser/diagram-loader.ts b/packages/glsp-client/src/diagram-loader.ts similarity index 75% rename from packages/glsp-client-theia/src/browser/diagram-loader.ts rename to packages/glsp-client/src/diagram-loader.ts index 001e5f07a..e308178a7 100644 --- a/packages/glsp-client-theia/src/browser/diagram-loader.ts +++ b/packages/glsp-client/src/diagram-loader.ts @@ -8,10 +8,10 @@ ********************************************************************************/ import { DiagramLoader, type DiagramLoadingOptions, StatusAction } from '@eclipse-glsp/client'; -import { ChannelLogger } from '@hydranium/client-theia/browser'; -import { Deferred, describeError } from '@hydranium/protocol'; -import { nls } from '@theia/core'; -import { inject, injectable } from '@theia/core/shared/inversify'; +import { Deferred, describeError, type Logger } from '@hydranium/protocol'; +import { inject, injectable } from 'inversify'; +import { DIAGRAM_LOAD_FAILED } from './diagram-load-failed'; +import { HydraniumClientTypes } from './types'; /** * How a diagram load ended. A discriminated union rather than a boolean or a @@ -38,8 +38,8 @@ export interface DiagramLoadFailure { * dispatcher is the thing that could not initialize. A consumer that covers * the canvas must then render the failure itself: it is the only surface * left, and removing its overlay would leave a blank diagram whose only - * explanation is a line in the Output channel — the exact failure mode this - * loader exists to prevent. + * explanation is a line in the log — the exact failure mode this loader + * exists to prevent. */ readonly surfaced: boolean; } @@ -52,7 +52,7 @@ export interface DiagramLoadFailure { * `RequestModelAction`, and the model-initialization constraint. Of these only * the `IDiagramStartup` hooks are wrapped by the base loader (it logs them to * `console.error`); the dispatcher-init / connection / request-model steps are - * not. The widget that drives loading + * not. The Theia widget that drives loading * (`@eclipse-glsp/theia-integration`'s `GLSPDiagramWidget.onAfterAttach`) * calls `load()` *without awaiting or catching it*, so any rejection becomes an * uncaught promise rejection visible only in the browser devtools console — @@ -60,24 +60,24 @@ export interface DiagramLoadFailure { * diagram just stays blank. * * This subclass closes that gap: it catches every load failure, routes it to - * the framework Output channel (the same {@link ChannelLogger} sink the server - * log and action traffic share, so it is discoverable where a developer already - * looks), and surfaces a persistent error to the user via a `severity: 'ERROR'` + * the host's logger (in Theia the Output channel the server log and action + * traffic share, so it is discoverable where a developer already looks), and + * surfaces a persistent error to the user via a `severity: 'ERROR'` * {@link StatusAction} on the diagram's status overlay. It deliberately does * **not** rethrow — the caller drops the promise, so rethrowing would only * reproduce the uncaught rejection this exists to prevent. * * It also publishes the load's terminal state ({@link loadOutcome} / * {@link onceLoadSettled}) so a pending indicator has one authority to key on. - * `HydraniumGlspDiagramWidget` uses it to cover the canvas while loading; see + * The Theia diagram widget uses it to cover the canvas while loading; see * {@link settle} for why the signal cannot be derived from the error * `StatusAction` instead. * - * Bound by `createGlspClientTheiaModule` via `rebind(DiagramLoader)`. + * Bound by `createGlspClientModule` via `rebind(DiagramLoader)`. */ @injectable() export class HydraniumDiagramLoader extends DiagramLoader { - @inject(ChannelLogger) protected readonly channel!: ChannelLogger; + @inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger; protected readonly settled = new Deferred(); protected outcome?: DiagramLoadOutcome; @@ -128,25 +128,25 @@ export class HydraniumDiagramLoader extends DiagramLoader { } /** - * Route a diagram-load failure to the Output channel and the status overlay. + * Route a diagram-load failure to the host's logger and the status overlay. * The status dispatch is itself guarded: when the action dispatcher is the * thing that failed to initialize, dispatching may throw too, and a failure * to *report* the failure must not re-escape as an uncaught rejection. * * @returns whether the `StatusAction` reached the dispatcher — i.e. whether the - * user can see this failure on the diagram. The channel line always happens, so + * user can see this failure on the diagram. The log line always happens, so * the return value is specifically about the *user-visible* report; a `false` * makes {@link DiagramLoadFailure.surfaced} false and hands the job to whoever * is covering the canvas. */ protected async reportLoadFailure(err: unknown): Promise { const label = this.loadFailureLabel(err); - this.channel.error(label, err); + this.hydraniumLogger.error(label, err); try { await this.actionDispatcher.dispatch(StatusAction.create(label, { severity: 'ERROR' })); return true; } catch (statusErr) { - this.channel.error('Failed to surface the diagram-load failure on the status overlay', statusErr); + this.hydraniumLogger.error('Failed to surface the diagram-load failure on the status overlay', statusErr); return false; } } @@ -156,23 +156,16 @@ export class HydraniumDiagramLoader extends DiagramLoader { * it. Public because the canvas overlay is one of those surfaces and lives in * another class, where a second copy of the sentence would drift. * - * **The seam and the catalogue key answer different questions, so it carries - * both.** Overriding this method changes the WORDING for every user of one - * adopter's product; the key is what gives a reader the sentence in their own - * language, and no override can supply that without an adopter writing a - * subclass per locale — which is the thing a catalogue exists to avoid. Two - * of the three surfaces this reaches are user-visible, so the audience - * triage answers it: a host-bound string a user sees takes an inline key. - * - * The Output-channel line is therefore localized too, which is deliberate - * rather than overlooked — the alternative is a second English copy of one - * sentence, and the reason for a single source here has not changed. + * English, formatted from {@link DIAGRAM_LOAD_FAILED}: this tier knows no + * locale. A host that does renders the code through its own mechanism by + * overriding this method, so every surface, the log line included, carries + * the one sentence the user reads. */ loadFailureLabel(err: unknown): string { - return nls.localize('hydranium/glsp-client-theia/diagram-load-failed', 'Diagram failed to load: {0}', this.formatError(err)); + return DIAGRAM_LOAD_FAILED.format({ reason: this.formatError(err) }); } - /** Render a thrown value as a message. Shared so the channel line, the status + /** Render a thrown value as a message. Shared so the log line, the status * overlay and any consumer rendering {@link DiagramLoadFailure.error} agree. */ protected formatError(err: unknown): string { return describeError(err); diff --git a/packages/glsp-client/src/glsp-client-module.ts b/packages/glsp-client/src/glsp-client-module.ts new file mode 100644 index 000000000..1c9aff2ad --- /dev/null +++ b/packages/glsp-client/src/glsp-client-module.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { + type BindingContext, + DiagramLoader, + GLSPActionDispatcher, + GLSPHiddenBoundsUpdater, + StatusOverlay, + TYPES +} from '@eclipse-glsp/client'; +import { type Clock, ConsoleLogger, DefaultTracer, type Logger, SystemClock } from '@hydranium/protocol'; +import { HydraniumGlspActionDispatcher } from './action-dispatcher'; +import { HydraniumDiagramLoader } from './diagram-loader'; +import { HydraniumHiddenBoundsUpdater } from './hidden-bounds-updater'; +import { SprottyLoggerAdapter } from './sprotty-logger-adapter'; +import { HydraniumStatusOverlay } from './status-overlay'; +import { HydraniumClientTypes } from './types'; + +/** Options for {@link createGlspClientModule}. */ +export interface GlspClientModuleOptions { + /** The host's logger, bound on `HydraniumClientTypes.Logger` in place of any + * binding the host made. Absent, the token keeps the host's own binding, or + * gets a `ConsoleLogger` when it has none. */ + readonly logger?: Logger; +} + +/** + * Binds the framework's client classes into a GLSP diagram container, whatever + * hosts it: the instrumented action dispatcher, the failure-reporting diagram + * loader, the route-repairing hidden-bounds updater, the status overlay that + * stays on the page, and a sprotty logger that reports through the host's. + * + * Call it from the module a host adds AFTER GLSP's defaults, since every class + * here replaces a token those defaults bind; `rebind` on an unbound token + * throws. A host that wants one GLSP default back rebinds that token after + * this call, the same one-line move this function makes. + * + * The logger and tracer tokens are bound only when the host has not bound + * them, so a host with a richer logger binds `HydraniumClientTypes.Logger` + * first and passes no option. The tracer composes the bound logger and clock. + * Bind the tokens in the container this runs in: `isBound` sees a parent + * container's binding, which `rebind` cannot replace. + */ +export function createGlspClientModule(context: BindingContext, options: GlspClientModuleOptions = {}): void { + const { bind, isBound, rebind } = context; + if (options.logger) { + (isBound(HydraniumClientTypes.Logger) ? rebind : bind)(HydraniumClientTypes.Logger).toConstantValue(options.logger); + } else if (!isBound(HydraniumClientTypes.Logger)) { + bind(HydraniumClientTypes.Logger).toConstantValue(new ConsoleLogger()); + } + if (!isBound(HydraniumClientTypes.Tracer)) { + bind(HydraniumClientTypes.Tracer) + .toDynamicValue(ctx => { + const logger = ctx.container.get(HydraniumClientTypes.Logger); + const clock = ctx.container.isBound(HydraniumClientTypes.Clock) + ? ctx.container.get(HydraniumClientTypes.Clock) + : new SystemClock(); + return new DefaultTracer(logger, clock); + }) + .inSingletonScope(); + } + bind(HydraniumGlspActionDispatcher).toSelf().inSingletonScope(); + rebind(GLSPActionDispatcher).toService(HydraniumGlspActionDispatcher); + rebind(DiagramLoader).to(HydraniumDiagramLoader).inSingletonScope(); + rebind(GLSPHiddenBoundsUpdater).to(HydraniumHiddenBoundsUpdater).inSingletonScope(); + rebind(StatusOverlay).to(HydraniumStatusOverlay).inSingletonScope(); + rebind(TYPES.ILogger).to(SprottyLoggerAdapter).inSingletonScope(); +} diff --git a/packages/glsp-client-theia/src/browser/glsp-client.ts b/packages/glsp-client/src/glsp-client.ts similarity index 92% rename from packages/glsp-client-theia/src/browser/glsp-client.ts rename to packages/glsp-client/src/glsp-client.ts index 1d408c634..42a72bbea 100644 --- a/packages/glsp-client-theia/src/browser/glsp-client.ts +++ b/packages/glsp-client/src/glsp-client.ts @@ -9,7 +9,7 @@ import { BaseJsonrpcGLSPClient, type DisposeClientSessionParameters } from '@eclipse-glsp/client'; -/** The GLSP client of `HydraniumGlspClientContribution`. */ +/** The GLSP client a host builds over its connection to the GLSP head. */ export class HydraniumGlspClient extends BaseJsonrpcGLSPClient { /** Resolves at once when the connection is gone: the session went with its * server, and upstream would throw, which a diagram disposed after its diff --git a/packages/glsp-client-theia/src/browser/hidden-bounds-updater.ts b/packages/glsp-client/src/hidden-bounds-updater.ts similarity index 93% rename from packages/glsp-client-theia/src/browser/hidden-bounds-updater.ts rename to packages/glsp-client/src/hidden-bounds-updater.ts index 908c5843c..305f5318a 100644 --- a/packages/glsp-client-theia/src/browser/hidden-bounds-updater.ts +++ b/packages/glsp-client/src/hidden-bounds-updater.ts @@ -19,15 +19,14 @@ import { ServerAction, isRoutable } from '@eclipse-glsp/client'; -import { ChannelLogger } from '@hydranium/client-theia/browser'; -import { Clock } from '@hydranium/client-theia/common'; -import { SystemClock } from '@hydranium/protocol'; -import { inject, injectable, optional } from '@theia/core/shared/inversify'; +import { type Clock, type Logger, SystemClock } from '@hydranium/protocol'; +import { inject, injectable, optional } from 'inversify'; import { type VNode } from 'snabbdom'; +import { HydraniumClientTypes } from './types'; /** How many scoped element ids the log line names before summarising the rest. * A scoped request names one element in practice; the cap only stops a - * pathological caller from writing a thousand ids into the channel. */ + * pathological caller from writing a thousand ids into the log. */ const SCOPE_ID_CAP = 3; /** @@ -66,14 +65,14 @@ const SCOPE_ID_CAP = 3; * paired `[#N start]` line per request. This is a per-occurrence record, so it * measures and composes its own line. * - * Bound by `createGlspClientTheiaModule` via `rebind(GLSPHiddenBoundsUpdater)`. + * Bound by `createGlspClientModule` via `rebind(GLSPHiddenBoundsUpdater)`. */ @injectable() export class HydraniumHiddenBoundsUpdater extends GLSPHiddenBoundsUpdater { - @inject(ChannelLogger) protected readonly channel!: ChannelLogger; + @inject(HydraniumClientTypes.Logger) protected readonly hydraniumLogger!: Logger; /** The diagram container's {@link Clock}, or a `SystemClock`; it only measures. */ - @inject(Clock) @optional() protected readonly clock: Clock = new SystemClock(); + @inject(HydraniumClientTypes.Clock) @optional() protected readonly clock: Clock = new SystemClock(); /** * Scope description for the in-flight request, read from the `cause` in @@ -139,7 +138,7 @@ export class HydraniumHiddenBoundsUpdater extends GLSPHiddenBoundsUpdater { // Read before the pass: the base class clears the map on its way out. const measured = this.getElement2BoundsData().size; const { elapsedMs } = this.clock.measure(() => super.getBoundsFromDOM()); - this.channel.trace(this.formatBoundsRequest(this.currentScope, measured, elapsedMs)); + this.hydraniumLogger.trace(this.formatBoundsRequest(this.currentScope, measured, elapsedMs)); } /** Format the per-request line. Override to change the wording. */ diff --git a/packages/glsp-client/src/index.ts b/packages/glsp-client/src/index.ts new file mode 100644 index 000000000..c280a5ce6 --- /dev/null +++ b/packages/glsp-client/src/index.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +// The host-neutral GLSP client classes and the binder that mounts them. This +// root is browser-bound, not neutral: it loads `@eclipse-glsp/client`, whose +// graph imports stylesheets, so it needs a bundler with a CSS loader. It is +// free of Node and of any host platform, which is the claim `check:neutral` +// gates. +export * from './action-dispatcher'; +export * from './diagram-load-failed'; +export * from './diagram-loader'; +export * from './glsp-client'; +export * from './glsp-client-module'; +export * from './hidden-bounds-updater'; +export * from './sprotty-logger-adapter'; +export * from './status-overlay'; +export * from './types'; diff --git a/packages/glsp-client/src/messages/index.ts b/packages/glsp-client/src/messages/index.ts new file mode 100644 index 000000000..7396f9049 --- /dev/null +++ b/packages/glsp-client/src/messages/index.ts @@ -0,0 +1,17 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +// Every user-facing message `@hydranium/glsp-client` raises. The client tier +// renders only its own messages, which fire when no server could have worded +// them: the loader formats the English, and a host that knows the reading +// user's locale renders the code through its own mechanism instead, as the +// Theia loader does through `nls`. The barrel is how an adopter rendering their +// own diagram chrome discovers the codes exist. + +export { DIAGRAM_LOAD_FAILED } from '../diagram-load-failed'; diff --git a/packages/glsp-client/src/sprotty-logger-adapter.ts b/packages/glsp-client/src/sprotty-logger-adapter.ts new file mode 100644 index 000000000..309815b6c --- /dev/null +++ b/packages/glsp-client/src/sprotty-logger-adapter.ts @@ -0,0 +1,121 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { type ILogger, LogLevel } from '@eclipse-glsp/client'; +import { Logger } from '@hydranium/protocol'; +import { inject, injectable } from 'inversify'; +import { HydraniumClientTypes } from './types'; + +/** GLSP's wording for a request it resolved silently after its timeout. If + * upstream rewords it, the notice degrades to `debug` with the rest of `info`. */ +const REQUEST_TIMED_OUT = /\btime out after \d+ms\b/; + +/** + * sprotty's `ILogger` over the host's {@link Logger}, so sprotty's own lines + * reach the same sink as the framework's: a view-registration failure lands in + * the Theia Output channel beside the action traffic, or on a page's console. + * sprotty binds a `NullLogger` by default, which drops those failures. + * + * sprotty's `error` and `warn` keep their levels and their parameters, where + * the parameter is the useful part, such as the element type that has no view. + * + * Its `info` is diagnosis, chatty on every hover's feedback, so it goes to + * `debug`; the one anomaly reported there, a request GLSP resolved silently + * after its timeout, goes to `warn`, where a default threshold shows it. + * + * Its `log` fires per action, per command and per render with the whole action + * or model as a parameter, so it goes to `trace`, where the framework + * dispatcher logs that traffic. **At `info` and `log` a parameter is reduced to + * its name**, an action's `kind` or an object's class, never forwarded whole: + * the Theia channel logger stringifies every parameter as JSON, so forwarding + * them would write the model into the Output channel on every action. + * + * Each line goes through a logger nested under the host's for the calling + * class, cached per class, so the origin is a tag the host renders inside its + * own, `[GLSP client :: Viewer]`, not a prefix inside the message. Where + * sprotty passes a sentence instead of the caller, as its layout registries + * do, the sentence prefixes the message. The adapter's own `logLevel` is + * sprotty's most verbose, so only the framework threshold filters; `info` and + * `log` check it first, since they run on every action and render and would + * otherwise format a line the threshold drops. + * + * Bound by `createGlspClientModule` via `rebind(TYPES.ILogger)`. + */ +@injectable() +export class SprottyLoggerAdapter implements ILogger { + @inject(HydraniumClientTypes.Logger) protected readonly logger!: Logger; + + logLevel: LogLevel = LogLevel.log; + + /** Loggers derived per calling class; sprotty logs from a few dozen classes. */ + protected readonly loggers = new Map(); + + error(thisArg: unknown, message: string, ...params: unknown[]): void { + this.loggerFor(thisArg).error(this.formatMessage(thisArg, message), ...params); + } + + warn(thisArg: unknown, message: string, ...params: unknown[]): void { + this.loggerFor(thisArg).warn(this.formatMessage(thisArg, message), ...params); + } + + info(thisArg: unknown, message: string, ...params: unknown[]): void { + if (REQUEST_TIMED_OUT.test(message)) { + this.loggerFor(thisArg).warn(this.formatMessage(thisArg, message, params)); + } else if (Logger.isLevelEnabled('debug')) { + this.loggerFor(thisArg).debug(this.formatMessage(thisArg, message, params)); + } + } + + log(thisArg: unknown, message: string, ...params: unknown[]): void { + if (Logger.isLevelEnabled('trace')) { + this.loggerFor(thisArg).trace(this.formatMessage(thisArg, message, params)); + } + } + + /** The host's logger with the calling class nested under its tag, or the + * host's own when the caller is not an object. */ + protected loggerFor(thisArg: unknown): Logger { + const caller = typeof thisArg === 'object' && thisArg !== null ? this.nameOf(thisArg) : undefined; + if (caller === undefined) { + return this.logger; + } + let derived = this.loggers.get(caller); + if (derived === undefined) { + derived = this.logger.sub(caller); + this.loggers.set(caller, derived); + } + return derived; + } + + /** The message, behind the sentence sprotty sometimes passes as the caller, + * followed by the parameters' names. */ + protected formatMessage(thisArg: unknown, message: string, params: readonly unknown[] = []): string { + const text = typeof thisArg === 'string' ? `${thisArg}${message}` : message; + return [text, ...params.map(param => this.nameOf(param)).filter(name => name !== undefined)].join(' '); + } + + /** An action's `kind`, also inside the message GLSP sends an action in, a + * string as it is, any other object's class, else nothing. */ + protected nameOf(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (typeof value !== 'object' || value === null) { + return undefined; + } + const { kind, action } = value as { kind?: unknown; action?: { kind?: unknown } }; + if (typeof kind === 'string') { + return kind; + } + if (typeof action?.kind === 'string') { + return action.kind; + } + return value.constructor?.name; + } +} diff --git a/packages/glsp-client-theia/src/browser/status-overlay.ts b/packages/glsp-client/src/status-overlay.ts similarity index 96% rename from packages/glsp-client-theia/src/browser/status-overlay.ts rename to packages/glsp-client/src/status-overlay.ts index 95612dae4..4e509695b 100644 --- a/packages/glsp-client-theia/src/browser/status-overlay.ts +++ b/packages/glsp-client/src/status-overlay.ts @@ -8,7 +8,7 @@ ********************************************************************************/ import { type StatusAction, StatusOverlay } from '@eclipse-glsp/client'; -import { injectable } from '@theia/core/shared/inversify'; +import { injectable } from 'inversify'; /** * GLSP's status overlay, kept on the page. diff --git a/packages/glsp-client-theia/src/testing/bind-recorder.ts b/packages/glsp-client/src/testing/bind-recorder.ts similarity index 100% rename from packages/glsp-client-theia/src/testing/bind-recorder.ts rename to packages/glsp-client/src/testing/bind-recorder.ts diff --git a/packages/glsp-client-theia/src/testing/index.ts b/packages/glsp-client/src/testing/index.ts similarity index 60% rename from packages/glsp-client-theia/src/testing/index.ts rename to packages/glsp-client/src/testing/index.ts index 7d3c77903..7cc49520c 100644 --- a/packages/glsp-client-theia/src/testing/index.ts +++ b/packages/glsp-client/src/testing/index.ts @@ -7,9 +7,7 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// Subpath barrel for `@hydranium/glsp-client-theia/testing` — the -// GLSP-module-specific Inversify test double. The cross-head doubles -// (`makeStubOutputChannelManager`, `makeStubInversifyContext`) live in -// `@hydranium/client-theia/testing`, alongside the logger they double for. +// Subpath barrel for `@hydranium/glsp-client/testing` — the Inversify binding +// recorder for testing a module factory without a container. export * from './bind-recorder'; diff --git a/packages/glsp-client/src/types.ts b/packages/glsp-client/src/types.ts new file mode 100644 index 000000000..2cea9a98d --- /dev/null +++ b/packages/glsp-client/src/types.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import type { Clock, Logger, Tracer } from '@hydranium/protocol'; +import type { interfaces } from 'inversify'; + +/** A fresh `Symbol` cast to inversify's typed identifier, so `bind` and + * `@inject` sites type-check against `T` rather than `any`. */ +function serviceIdentifier(description: string): interfaces.ServiceIdentifier { + return Symbol(description) as interfaces.ServiceIdentifier; +} + +/** + * The DI tokens the framework's client classes inject, typed by the protocol + * interfaces so a host binds whatever implementation it has: a Theia frontend + * binds its Output-channel logger, a page or webview a console logger. Kept in + * a registry so a token's name cannot collide with the type it injects. + * + * `Logger` and `Tracer` are required, so a host that binds neither fails at + * resolution rather than running silent; `createGlspClientModule` binds + * defaults for a host that passes none. `Clock` is optional: the classes fall + * back to a `SystemClock`. + */ +export const HydraniumClientTypes = { + Logger: serviceIdentifier('HydraniumClientLogger'), + Tracer: serviceIdentifier('HydraniumClientTracer'), + Clock: serviceIdentifier('HydraniumClientClock') +} as const; diff --git a/packages/glsp-client-theia/test/browser/action-dispatcher.test.ts b/packages/glsp-client/test/action-dispatcher.test.ts similarity index 92% rename from packages/glsp-client-theia/test/browser/action-dispatcher.test.ts rename to packages/glsp-client/test/action-dispatcher.test.ts index de2941de9..fb60034e6 100644 --- a/packages/glsp-client-theia/test/browser/action-dispatcher.test.ts +++ b/packages/glsp-client/test/action-dispatcher.test.ts @@ -7,15 +7,6 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// The SUT injects `ChannelLogger` from `@hydranium/client-theia/browser`, whose real -// module loads `@theia/output` → `@theia/monaco` → `@lumino/widgets` (DOM globals at -// module load, unavailable in the node test env). The dispatcher tests never resolve a -// container, so a bare token stand-in is enough. -vi.mock('@hydranium/client-theia/browser', () => ({ - ChannelLogger: class ChannelLogger {}, - ChannelTracer: Symbol('ChannelTracer') -})); - import { describe, expect, it, vi } from 'vitest'; import { type Action, @@ -35,11 +26,7 @@ import { SetTypeHintsAction, StatusAction } from '@eclipse-glsp/client'; -import { - HYDRANIUM_DEFAULT_KIND_PAIRS, - HYDRANIUM_DEFAULT_LOGGED_KINDS, - HydraniumGlspActionDispatcher -} from '../../src/browser/action-dispatcher'; +import { HYDRANIUM_DEFAULT_KIND_PAIRS, HYDRANIUM_DEFAULT_LOGGED_KINDS, HydraniumGlspActionDispatcher } from '../src/action-dispatcher'; // Test subclass exposes protected members for assertion + skips the inherited // initialise pipeline (which needs a full GLSP container). @@ -71,8 +58,8 @@ class TestableDispatcher extends HydraniumGlspActionDispatcher { setTracer(tracer: unknown): void { (this as unknown as { tracer: unknown }).tracer = tracer; } - setChannel(channel: unknown): void { - (this as unknown as { channel: unknown }).channel = channel; + setLogger(logger: unknown): void { + (this as unknown as { hydraniumLogger: unknown }).hydraniumLogger = logger; } } @@ -139,15 +126,15 @@ describe('HydraniumGlspActionDispatcher.registerInitTiming', () => { it('swallows + logs a tracer failure so instrumentation cannot abort diagram init', () => { const dispatcher = new TestableDispatcher(); - const channel = { error: vi.fn() }; - dispatcher.setChannel(channel); + const logger = { error: vi.fn() }; + dispatcher.setLogger(logger); dispatcher.setTracer({ time: () => { throw new Error('stale tracer API'); } }); expect(() => dispatcher.exposeRegisterInitTiming()).not.toThrow(); - expect(channel.error).toHaveBeenCalledWith('Failed to register model-initialized timing instrumentation', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to register model-initialized timing instrumentation', expect.any(Error)); }); }); @@ -301,10 +288,10 @@ describe('HydraniumGlspActionDispatcher.summarize defaults', () => { }); describe('HydraniumGlspActionDispatcher logging an edge check', () => { - it('logs the check and its verdict to the channel as one timed request and response', () => { + it('logs the check and its verdict as one timed request and response', () => { const dispatcher = new TestableDispatcher(); - const channel = { trace: vi.fn() }; - dispatcher.setChannel(channel); + const logger = { trace: vi.fn() }; + dispatcher.setLogger(logger); dispatcher.setActionHandlerRegistry({ get: () => [] }); const check = asAction({ kind: RequestCheckEdgeAction.KIND, @@ -327,7 +314,7 @@ describe('HydraniumGlspActionDispatcher logging an edge check', () => { dispatcher.exposeLogAction(check); dispatcher.exposeLogAction(result); - const lines = channel.trace.mock.calls.map(([line]) => String(line)); + const lines = logger.trace.mock.calls.map(([line]) => String(line)); expect(lines[0]).toMatch( /^Process action 'requestCheckEdge' \[client → client] \[edgeType=edge:one source=a target=b] \[request #1]$/ ); diff --git a/packages/glsp-client-theia/test/browser/diagram-loader.test.ts b/packages/glsp-client/test/diagram-loader.test.ts similarity index 75% rename from packages/glsp-client-theia/test/browser/diagram-loader.test.ts rename to packages/glsp-client/test/diagram-loader.test.ts index 0aa447496..3db3dc37a 100644 --- a/packages/glsp-client-theia/test/browser/diagram-loader.test.ts +++ b/packages/glsp-client/test/diagram-loader.test.ts @@ -7,23 +7,16 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// The real `@hydranium/client-theia/browser` module loads `@theia/output` → DOM -// globals unavailable in the node test env. A bare class stand-in is enough since -// the loader is constructed directly, never via a container. -vi.mock('@hydranium/client-theia/browser', () => ({ - ChannelLogger: class ChannelLogger {} -})); - import { type Action, StatusAction } from '@eclipse-glsp/client'; -import { nls } from '@theia/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HydraniumDiagramLoader } from '../../src/browser/diagram-loader'; +import { DIAGRAM_LOAD_FAILED } from '../src/diagram-load-failed'; +import { HydraniumDiagramLoader } from '../src/diagram-loader'; /** Exposes the protected reporting hook and lets the test wire the protected * injected collaborators without resolving a GLSP container. */ class TestableLoader extends HydraniumDiagramLoader { - setChannel(channel: unknown): void { - (this as unknown as { channel: unknown }).channel = channel; + setLogger(logger: unknown): void { + (this as unknown as { hydraniumLogger: unknown }).hydraniumLogger = logger; } setActionDispatcher(dispatcher: unknown): void { (this as unknown as { actionDispatcher: unknown }).actionDispatcher = dispatcher; @@ -58,15 +51,15 @@ class TestableLoader extends HydraniumDiagramLoader { } describe('HydraniumDiagramLoader', () => { - let channel: { error: ReturnType }; + let logger: { error: ReturnType }; let dispatch: ReturnType; let loader: TestableLoader; beforeEach(() => { - channel = { error: vi.fn() }; + logger = { error: vi.fn() }; dispatch = vi.fn().mockResolvedValue(undefined); loader = new TestableLoader(); - loader.setChannel(channel); + loader.setLogger(logger); loader.setActionDispatcher({ dispatch }); }); @@ -75,11 +68,11 @@ describe('HydraniumDiagramLoader', () => { await expect(loader.load()).resolves.toBeUndefined(); }); - it('routes a load failure to the Output channel', async () => { + it('routes a load failure to the logger', async () => { const err = new Error('connection refused'); loader.makeSuperLoadThrow(err); await loader.load(); - expect(channel.error).toHaveBeenCalledWith('Diagram failed to load: connection refused', err); + expect(logger.error).toHaveBeenCalledWith('Diagram failed to load: connection refused', err); }); it('surfaces a persistent ERROR StatusAction on the status overlay', async () => { @@ -97,7 +90,7 @@ describe('HydraniumDiagramLoader', () => { it('stringifies non-Error rejection values', async () => { await loader.exposeReportLoadFailure('boom'); - expect(channel.error).toHaveBeenCalledWith('Diagram failed to load: boom', 'boom'); + expect(logger.error).toHaveBeenCalledWith('Diagram failed to load: boom', 'boom'); const status = dispatch.mock.calls[0][0] as StatusAction; expect(status.message).toBe('Diagram failed to load: boom'); }); @@ -106,8 +99,8 @@ describe('HydraniumDiagramLoader', () => { dispatch.mockRejectedValueOnce(new Error('dispatcher down')); await expect(loader.exposeReportLoadFailure(new Error('connection refused'))).resolves.toBe(false); // Primary failure + secondary (status-surface) failure are both logged. - expect(channel.error).toHaveBeenCalledWith('Diagram failed to load: connection refused', expect.any(Error)); - expect(channel.error).toHaveBeenCalledWith('Failed to surface the diagram-load failure on the status overlay', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Diagram failed to load: connection refused', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to surface the diagram-load failure on the status overlay', expect.any(Error)); }); it('reports whether the failure reached the status overlay', async () => { @@ -115,25 +108,15 @@ describe('HydraniumDiagramLoader', () => { }); /** - * The KEY, not the sentence. Every assertion above reads the English default, - * which `nls.localize` returns unchanged when no catalogue is loaded — so all - * of them pass just as well with the key renamed, or with the call removed - * entirely. The code is the contract and the English is the fallback, so the - * contract needs an assertion of its own. - * - * The parameter is asserted here too, because `{0}` is what makes this one - * entry rather than one per error text: a catalogue cannot hold a translation - * for a sentence whose detail is baked into the key's default. + * The CODE, not the sentence. Every assertion above reads the English, which + * passes just as well with the declaration renamed. The code is what a host + * with a locale renders through, so it needs an assertion of its own, and + * the placeholder is what makes this one entry rather than one per error. */ - it('renders the failure through its catalogue key, with the detail as a parameter', async () => { - const localize = vi.spyOn(nls, 'localize'); - loader.loadFailureLabel(new Error('connection refused')); - expect(localize).toHaveBeenCalledWith( - 'hydranium/glsp-client-theia/diagram-load-failed', - 'Diagram failed to load: {0}', - 'connection refused' - ); - localize.mockRestore(); + it('reports the failure as the declared message with the detail as its parameter', () => { + expect(DIAGRAM_LOAD_FAILED.code).toBe('hydranium/glsp-client/diagram-load-failed'); + expect(DIAGRAM_LOAD_FAILED.text).toBe('Diagram failed to load: {reason}'); + expect(loader.loadFailureLabel(new Error('connection refused'))).toBe(DIAGRAM_LOAD_FAILED.format({ reason: 'connection refused' })); }); describe('load outcome', () => { diff --git a/packages/glsp-client/test/glsp-client-module.resolution.test.ts b/packages/glsp-client/test/glsp-client-module.resolution.test.ts new file mode 100644 index 000000000..240df12d9 --- /dev/null +++ b/packages/glsp-client/test/glsp-client-module.resolution.test.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import 'reflect-metadata'; +import { + createDiagramOptionsModule, + DiagramLoader, + GLSPActionDispatcher, + GLSPHiddenBoundsUpdater, + initializeDiagramContainer, + StatusOverlay, + TYPES +} from '@eclipse-glsp/client'; +import { NoopLogger, SystemClock } from '@hydranium/protocol'; +import { makeFakeClock } from '@hydranium/protocol/testing'; +import { Container, ContainerModule } from 'inversify'; +import { describe, expect, it } from 'vitest'; +import { HydraniumGlspActionDispatcher } from '../src/action-dispatcher'; +import { HydraniumDiagramLoader } from '../src/diagram-loader'; +import { createGlspClientModule } from '../src/glsp-client-module'; +import { HydraniumHiddenBoundsUpdater } from '../src/hidden-bounds-updater'; +import { SprottyLoggerAdapter } from '../src/sprotty-logger-adapter'; +import { HydraniumStatusOverlay } from '../src/status-overlay'; +import { HydraniumClientTypes } from '../src/types'; + +/** What the dispatcher was given, which it keeps protected. */ +function injectedInto(dispatcher: unknown): { hydraniumLogger: unknown; clock: unknown } { + return dispatcher as { hydraniumLogger: unknown; clock: unknown }; +} + +/** + * A real GLSP container over GLSP's own default modules, so the `@inject` + * tokens on the moved classes are exercised by resolution: a wrong token on + * one of them would pass the recorder-based suite and fail only on a host. + */ +function makeGlspContainer(...hostModules: ContainerModule[]): Container { + return initializeDiagramContainer( + new Container(), + createDiagramOptionsModule({ clientId: 'one', diagramType: 'diagram-one', glspClientProvider: async () => ({}) as never }), + ...hostModules + ); +} + +describe('createGlspClientModule in a GLSP container', () => { + const logger = new NoopLogger(); + const hydraniumModule = new ContainerModule((bind, unbind, isBound, rebind) => { + createGlspClientModule({ bind, unbind, isBound, rebind }, { logger }); + }); + + it('resolves the real classes through the tokens the binder replaces', () => { + const container = makeGlspContainer(hydraniumModule); + expect(container.get(GLSPActionDispatcher)).toBeInstanceOf(HydraniumGlspActionDispatcher); + expect(container.get(DiagramLoader)).toBeInstanceOf(HydraniumDiagramLoader); + expect(container.get(GLSPHiddenBoundsUpdater)).toBeInstanceOf(HydraniumHiddenBoundsUpdater); + expect(container.get(StatusOverlay)).toBeInstanceOf(HydraniumStatusOverlay); + expect(container.get(TYPES.ILogger)).toBeInstanceOf(SprottyLoggerAdapter); + }); + + it('hands the classes the bound logger and a system clock when none is bound', () => { + const dispatcher = injectedInto(makeGlspContainer(hydraniumModule).get(GLSPActionDispatcher)); + expect(dispatcher.hydraniumLogger).toBe(logger); + expect(dispatcher.clock).toBeInstanceOf(SystemClock); + }); + + it('hands the classes the clock a host binds', () => { + const clock = makeFakeClock(); + const clockModule = new ContainerModule(bind => { + bind(HydraniumClientTypes.Clock).toConstantValue(clock); + }); + expect(injectedInto(makeGlspContainer(hydraniumModule, clockModule).get(GLSPActionDispatcher)).clock).toBe(clock); + }); +}); diff --git a/packages/glsp-client/test/glsp-client-module.test.ts b/packages/glsp-client/test/glsp-client-module.test.ts new file mode 100644 index 000000000..1437aced3 --- /dev/null +++ b/packages/glsp-client/test/glsp-client-module.test.ts @@ -0,0 +1,156 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import 'reflect-metadata'; +import { DiagramLoader, GLSPActionDispatcher, GLSPHiddenBoundsUpdater, StatusOverlay, TYPES } from '@eclipse-glsp/client'; +import { ConsoleLogger, DefaultTracer, type Logger, NoopLogger, SystemClock } from '@hydranium/protocol'; +import { makeFakeClock } from '@hydranium/protocol/testing'; +import { Container, type interfaces } from 'inversify'; +import { describe, expect, it } from 'vitest'; +import { HydraniumGlspActionDispatcher } from '../src/action-dispatcher'; +import { HydraniumDiagramLoader } from '../src/diagram-loader'; +import { createGlspClientModule, type GlspClientModuleOptions } from '../src/glsp-client-module'; +import { HydraniumHiddenBoundsUpdater } from '../src/hidden-bounds-updater'; +import { SprottyLoggerAdapter } from '../src/sprotty-logger-adapter'; +import { HydraniumStatusOverlay } from '../src/status-overlay'; +import { HydraniumClientTypes } from '../src/types'; +import { type BindRecorder, makeBindRecorder } from '../src/testing/bind-recorder'; + +/** The GLSP default tokens the binder replaces, bound as GLSP's own modules + * would have, so each `rebind` finds something to replace. */ +const GLSP_DEFAULT_TOKENS: interfaces.ServiceIdentifier[] = [ + GLSPActionDispatcher, + DiagramLoader, + GLSPHiddenBoundsUpdater, + StatusOverlay, + TYPES.ILogger +]; + +/** A real container, so the token defaults are checked by resolving them. */ +function makeContainer(): Container { + const container = new Container(); + for (const token of GLSP_DEFAULT_TOKENS) { + container.bind(token).toConstantValue({}); + } + return container; +} + +function bindInto(container: Container, options?: GlspClientModuleOptions): void { + createGlspClientModule( + { + bind: container.bind.bind(container), + unbind: container.unbind.bind(container), + isBound: container.isBound.bind(container), + rebind: container.rebind.bind(container) + }, + options + ); +} + +/** The tracer's composed logger and clock, which `DefaultTracer` keeps protected. */ +function partsOf(tracer: unknown): { logger: unknown; clock: unknown } { + return tracer as { logger: unknown; clock: unknown }; +} + +describe('createGlspClientModule', () => { + function record(): BindRecorder { + const recorder = makeBindRecorder(); + createGlspClientModule(recorder); + return recorder; + } + + it('binds HydraniumGlspActionDispatcher as a self-bound singleton', () => { + expect(record().find('bind', HydraniumGlspActionDispatcher)?.chain).toEqual(['toSelf()', 'inSingletonScope()']); + }); + + it('rebinds GLSPActionDispatcher to point at HydraniumGlspActionDispatcher', () => { + expect(record().find('rebind', GLSPActionDispatcher)?.chain).toEqual([`toService(<${HydraniumGlspActionDispatcher.name}>)`]); + }); + + it('rebinds DiagramLoader to the framework loader', () => { + expect(record().find('rebind', DiagramLoader)?.chain).toEqual([`to(<${HydraniumDiagramLoader.name}>)`, 'inSingletonScope()']); + }); + + it('rebinds GLSPHiddenBoundsUpdater to the instrumented updater', () => { + expect(record().find('rebind', GLSPHiddenBoundsUpdater)?.chain).toEqual([ + `to(<${HydraniumHiddenBoundsUpdater.name}>)`, + 'inSingletonScope()' + ]); + }); + + it('rebinds StatusOverlay to the one that keeps its element on the page', () => { + expect(record().find('rebind', StatusOverlay)?.chain).toEqual([`to(<${HydraniumStatusOverlay.name}>)`, 'inSingletonScope()']); + }); + + it("rebinds sprotty's logger to the adapter over the host's logger", () => { + expect(record().find('rebind', TYPES.ILogger)?.chain).toEqual([`to(<${SprottyLoggerAdapter.name}>)`, 'inSingletonScope()']); + }); + + describe('the logger token', () => { + it('binds the logger the host passes', () => { + const container = makeContainer(); + const logger = new NoopLogger(); + bindInto(container, { logger }); + expect(container.get(HydraniumClientTypes.Logger)).toBe(logger); + }); + + it('binds a console logger when the host passes none and bound none', () => { + const container = makeContainer(); + bindInto(container); + expect(container.get(HydraniumClientTypes.Logger)).toBeInstanceOf(ConsoleLogger); + }); + + it('replaces a logger the host bound with the one it passes', () => { + // Two bindings on one token make every resolution ambiguous, so the + // option must replace rather than add. + const container = makeContainer(); + container.bind(HydraniumClientTypes.Logger).toConstantValue(new NoopLogger()); + const passed = new NoopLogger(); + bindInto(container, { logger: passed }); + expect(container.get(HydraniumClientTypes.Logger)).toBe(passed); + }); + + it("leaves the host's own logger binding alone when it passes none", () => { + const container = makeContainer(); + const own = new NoopLogger(); + container.bind(HydraniumClientTypes.Logger).toConstantValue(own); + bindInto(container); + expect(container.get(HydraniumClientTypes.Logger)).toBe(own); + }); + }); + + describe('the tracer token', () => { + it('composes a singleton tracer from the bound logger and a system clock when the host bound no clock', () => { + const container = makeContainer(); + const logger = new NoopLogger(); + bindInto(container, { logger }); + const tracer = container.get(HydraniumClientTypes.Tracer); + expect(tracer).toBeInstanceOf(DefaultTracer); + expect(partsOf(tracer).logger).toBe(logger); + expect(partsOf(tracer).clock).toBeInstanceOf(SystemClock); + expect(container.get(HydraniumClientTypes.Tracer)).toBe(tracer); + }); + + it('composes the tracer over the clock the host bound', () => { + const container = makeContainer(); + const clock = makeFakeClock(); + container.bind(HydraniumClientTypes.Clock).toConstantValue(clock); + bindInto(container); + expect(partsOf(container.get(HydraniumClientTypes.Tracer)).clock).toBe(clock); + }); + + it("leaves the host's own tracer binding alone", () => { + const container = makeContainer(); + const own = new DefaultTracer(); + container.bind(HydraniumClientTypes.Tracer).toConstantValue(own); + bindInto(container); + expect(container.get(HydraniumClientTypes.Tracer)).toBe(own); + }); + }); +}); diff --git a/packages/glsp-client-theia/test/browser/glsp-client.test.ts b/packages/glsp-client/test/glsp-client.test.ts similarity index 95% rename from packages/glsp-client-theia/test/browser/glsp-client.test.ts rename to packages/glsp-client/test/glsp-client.test.ts index d34c5fe24..db1c6feda 100644 --- a/packages/glsp-client-theia/test/browser/glsp-client.test.ts +++ b/packages/glsp-client/test/glsp-client.test.ts @@ -8,7 +8,7 @@ ********************************************************************************/ import { describe, expect, it, vi } from 'vitest'; -import { HydraniumGlspClient } from '../../src/browser/glsp-client'; +import { HydraniumGlspClient } from '../src/glsp-client'; const params = { clientSessionId: 'test_0' }; diff --git a/packages/glsp-client-theia/test/browser/hidden-bounds-updater.test.ts b/packages/glsp-client/test/hidden-bounds-updater.test.ts similarity index 96% rename from packages/glsp-client-theia/test/browser/hidden-bounds-updater.test.ts rename to packages/glsp-client/test/hidden-bounds-updater.test.ts index 72b5a1a69..51e36c00f 100644 --- a/packages/glsp-client-theia/test/browser/hidden-bounds-updater.test.ts +++ b/packages/glsp-client/test/hidden-bounds-updater.test.ts @@ -7,14 +7,6 @@ * SPDX-License-Identifier: MIT ********************************************************************************/ -// The real `@hydranium/client-theia/browser` module loads `@theia/output` → DOM -// globals unavailable in the node test env. The updater is constructed directly, -// never via a container, so token stand-ins do. -vi.mock('@hydranium/client-theia/browser', () => ({ - ChannelLogger: class ChannelLogger {}, - ChannelTracer: Symbol('ChannelTracer') -})); - import { type Action, type ElementAndRoutingPoints, @@ -30,9 +22,9 @@ import { } from '@eclipse-glsp/client'; import { type Clock } from '@hydranium/protocol'; import { makeFakeClock } from '@hydranium/protocol/testing'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { type VNode } from 'snabbdom'; -import { HydraniumHiddenBoundsUpdater } from '../../src/browser/hidden-bounds-updater'; +import { HydraniumHiddenBoundsUpdater } from '../src/hidden-bounds-updater'; /** * A {@link Clock} reporting a caller-controlled duration from `measure`, so the @@ -66,7 +58,7 @@ class TestableUpdater extends HydraniumHiddenBoundsUpdater { constructor() { super(); - (this as unknown as { channel: unknown }).channel = { + (this as unknown as { hydraniumLogger: unknown }).hydraniumLogger = { trace: (message: string) => this.traceLines.push(message) }; (this as unknown as { layouter: unknown }).layouter = { layout: () => undefined }; diff --git a/packages/glsp-client/test/sprotty-logger-adapter.test.ts b/packages/glsp-client/test/sprotty-logger-adapter.test.ts new file mode 100644 index 000000000..67ee28f2d --- /dev/null +++ b/packages/glsp-client/test/sprotty-logger-adapter.test.ts @@ -0,0 +1,148 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import 'reflect-metadata'; +import { LogLevel } from '@eclipse-glsp/client'; +import { AbstractLogger, type LogLevel as HydraniumLogLevel, Logger } from '@hydranium/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SprottyLoggerAdapter } from '../src/sprotty-logger-adapter'; + +class Viewer {} +class SetModelCommand {} + +interface Line { + readonly level: HydraniumLogLevel; + readonly component?: string; + readonly message: string; + readonly args: readonly unknown[]; +} + +/** + * A real `AbstractLogger`, so `for`, `sub` and `with` carry their real + * component semantics: a double that records the name it was handed cannot + * tell a nested tag from a replaced one. + */ +class CapturingLogger extends AbstractLogger { + constructor( + readonly lines: Line[], + component?: string + ) { + super(component); + } + + protected emit(level: HydraniumLogLevel, _label: string, message: string, args: readonly unknown[]): void { + this.lines.push({ level, component: this.component, message, args }); + } + + protected derive(component: string): this { + return new CapturingLogger(this.lines, component) as this; + } +} + +/** The adapter over a host logger already tagged, as the browser page tags its. */ +function makeAdapter(): { adapter: SprottyLoggerAdapter; lines: Line[] } { + const lines: Line[] = []; + const adapter = new SprottyLoggerAdapter(); + Object.assign(adapter, { logger: new CapturingLogger(lines).with('GLSP client') }); + return { adapter, lines }; +} + +describe('SprottyLoggerAdapter', () => { + let previousLevel: ReturnType; + beforeEach(() => { + previousLevel = Logger.getLevel(); + Logger.setLevel('trace'); + }); + afterEach(() => { + Logger.setLevel(previousLevel); + }); + + it("nests the calling class under the host's own tag and forwards an error's parameters whole", () => { + const { adapter, lines } = makeAdapter(); + adapter.error(new Viewer(), 'No view registered', 'type-one'); + expect(lines).toEqual([{ level: 'error', component: 'GLSP client :: Viewer', message: 'No view registered', args: ['type-one'] }]); + }); + + it('forwards a warning with its parameters whole', () => { + const { adapter, lines } = makeAdapter(); + adapter.warn(new Viewer(), 'Unknown element', { id: 'one' }); + expect(lines).toEqual([{ level: 'warn', component: 'GLSP client :: Viewer', message: 'Unknown element', args: [{ id: 'one' }] }]); + }); + + it("prefixes the message with the sentence sprotty's layout registry passes as the caller", () => { + // sprotty's `logger.warn('Layout kind is already defined: ', layout.layoutKind)`. + const { adapter, lines } = makeAdapter(); + adapter.warn('Layout kind is already defined: ', 'hbox'); + expect(lines).toEqual([{ level: 'warn', component: 'GLSP client', message: 'Layout kind is already defined: hbox', args: [] }]); + }); + + it('demotes info to debug, naming its parameters rather than forwarding them', () => { + const { adapter, lines } = makeAdapter(); + adapter.info(new Viewer(), 'Dispatched feedback', { kind: 'whole-model', children: [] }); + expect(lines).toEqual([{ level: 'debug', component: 'GLSP client :: Viewer', message: 'Dispatched feedback whole-model', args: [] }]); + }); + + it("raises GLSP's silently resolved request timeout to warn", () => { + const { adapter, lines } = makeAdapter(); + adapter.info(new Viewer(), 'Request one (requestModel) time out after 2000ms.'); + expect(lines.map(line => line.level)).toEqual(['warn']); + }); + + it('demotes log to trace, naming an action by its kind and an object by its class', () => { + const { adapter, lines } = makeAdapter(); + adapter.log(new Viewer(), 'Handle', { kind: 'requestModel', requestId: 'one' }); + adapter.log(new Viewer(), 'Executing', new SetModelCommand()); + expect(lines.map(line => [line.level, line.message])).toEqual([ + ['trace', 'Handle requestModel'], + ['trace', 'Executing SetModelCommand'] + ]); + }); + + it('names the action inside the message GLSP sends, so the outgoing half reads as the incoming one', () => { + const { adapter, lines } = makeAdapter(); + adapter.log(new Viewer(), 'sending', { clientId: 'one', action: { kind: 'requestModel' } }); + expect(lines.map(line => line.message)).toEqual(['sending requestModel']); + }); + + it('formats no line that the threshold would drop', () => { + // `log` and `info` run on every action and render; the line must not be + // built only to be discarded by the host logger. + Logger.setLevel('info'); + const { adapter, lines } = makeAdapter(); + const formatMessage = vi.spyOn(adapter as unknown as { formatMessage: () => string }, 'formatMessage'); + adapter.log(new Viewer(), 'Handle', { kind: 'requestModel' }); + adapter.info(new Viewer(), 'Dispatched feedback'); + expect(formatMessage).not.toHaveBeenCalled(); + expect(lines).toEqual([]); + }); + + it('survives a parameter with no prototype', () => { + const { adapter, lines } = makeAdapter(); + adapter.log(new Viewer(), 'Handle', Object.create(null)); + expect(lines.map(line => line.message)).toEqual(['Handle']); + }); + + it('derives one logger per calling class', () => { + const { adapter, lines } = makeAdapter(); + adapter.warn(new Viewer(), 'one'); + adapter.warn(new Viewer(), 'two'); + expect(lines.map(line => line.component)).toEqual(['GLSP client :: Viewer', 'GLSP client :: Viewer']); + expect((adapter as unknown as { loggers: Map }).loggers.size).toBe(1); + }); + + it("logs under the host's own tag when the caller is neither an object nor a sentence", () => { + const { adapter, lines } = makeAdapter(); + adapter.error(undefined, 'failed'); + expect(lines).toEqual([{ level: 'error', component: 'GLSP client', message: 'failed', args: [] }]); + }); + + it("lets the framework threshold filter by reporting sprotty's most verbose level", () => { + expect(makeAdapter().adapter.logLevel).toBe(LogLevel.log); + }); +}); diff --git a/packages/glsp-client-theia/test/browser/status-overlay.test.ts b/packages/glsp-client/test/status-overlay.test.ts similarity index 96% rename from packages/glsp-client-theia/test/browser/status-overlay.test.ts rename to packages/glsp-client/test/status-overlay.test.ts index b6b27d86c..093bc6fa6 100644 --- a/packages/glsp-client-theia/test/browser/status-overlay.test.ts +++ b/packages/glsp-client/test/status-overlay.test.ts @@ -10,7 +10,7 @@ import 'reflect-metadata'; import { StatusAction } from '@eclipse-glsp/client'; import { describe, expect, it, vi } from 'vitest'; -import { HydraniumStatusOverlay } from '../../src/browser/status-overlay'; +import { HydraniumStatusOverlay } from '../src/status-overlay'; /** An overlay whose element and parent are stand-ins, so no DOM is needed. */ class TestOverlay extends HydraniumStatusOverlay { diff --git a/packages/glsp-client-theia/test/testing/bind-recorder.test.ts b/packages/glsp-client/test/testing/bind-recorder.test.ts similarity index 100% rename from packages/glsp-client-theia/test/testing/bind-recorder.test.ts rename to packages/glsp-client/test/testing/bind-recorder.test.ts diff --git a/packages/glsp-client/test/tsconfig.json b/packages/glsp-client/test/tsconfig.json new file mode 100644 index 000000000..4683f3149 --- /dev/null +++ b/packages/glsp-client/test/tsconfig.json @@ -0,0 +1,15 @@ +// The test tree's project, which an editor and vitest's transform both find +// by name; the transform takes its compiler options, legacy decorators +// included, from here. `typecheck:test` checks the same files through +// `../tsconfig.test.json`. Without this file the TS server infers a project +// that loads every `@types` package, and every test shows `TS2403` clashes +// between `@types/mocha` and `@types/jest`. +// +// `include` is declared, not inherited: the transform applies a config only to +// files its own `include` lists, so a test declaring a decorated class would +// fail to load. `../src` is listed because the tests import it, and a +// composite project rejects an import it does not include (TS6307). +{ + "extends": "../tsconfig.test.json", + "include": ["../src", "."] +} diff --git a/packages/glsp-client/tsconfig.json b/packages/glsp-client/tsconfig.json new file mode 100644 index 000000000..f6ea6e402 --- /dev/null +++ b/packages/glsp-client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "//lib": "DOM, because the status overlay reattaches an `HTMLElement`; the neutrality gate reports the grant per entry.", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "references": [{ "path": "../protocol" }] +} diff --git a/packages/glsp-client/tsconfig.test.json b/packages/glsp-client/tsconfig.test.json new file mode 100644 index 000000000..dc28caecd --- /dev/null +++ b/packages/glsp-client/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/glsp-client/vitest.config.ts b/packages/glsp-client/vitest.config.ts new file mode 100644 index 000000000..1fae35a4a --- /dev/null +++ b/packages/glsp-client/vitest.config.ts @@ -0,0 +1,32 @@ +/******************************************************************************** + * Copyright (c) 2026 CrossBreeze, EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the MIT License which is available in the project root. + * + * SPDX-License-Identifier: MIT + ********************************************************************************/ + +import { mergeConfig } from 'vitest/config'; +import { definePackageVitestConfig } from '../../vitest.shared'; + +// `@eclipse-glsp/client`'s built modules `require` CSS files. Vitest externalizes +// node_modules (loads them through Node untransformed), so Node would parse that +// CSS as JS. Pre-bundle the GLSP packages through Vitest's dep optimizer instead +// — paid once, then cached — and have the bundler replace every `.css` import +// with an empty module. The prebundle lands in `node_modules/.vite/vitest/`, +// which turbo neither hashes nor cleans; a damaged one fails every suite here +// with `Cannot find module …/deps_ssr/.js`, and `npm run clean` sweeps it. +export default mergeConfig(definePackageVitestConfig('glsp-client'), { + test: { + deps: { + optimizer: { + ssr: { + enabled: true, + include: ['@eclipse-glsp/client', '@eclipse-glsp/sprotty', 'sprotty'], + rolldownOptions: { moduleTypes: { '.css': 'empty' } } + } + } + } + } +}); diff --git a/packages/glsp-server/README.md b/packages/glsp-server/README.md index f030edd51..a5348c14a 100644 --- a/packages/glsp-server/README.md +++ b/packages/glsp-server/README.md @@ -4,7 +4,8 @@ The graphical-editing head of [Hydranium](https://github.com/eclipse-emfcloud/hydranium): a [GLSP](https://eclipse.dev/glsp/) server that draws diagrams from your model and writes their edits back to it. Install it in the server that composes -`@hydranium/core`; in Theia, `@hydranium/glsp-client-theia` is its client. +`@hydranium/core`; `@hydranium/glsp-client` is its client in any host, and +`@hydranium/glsp-client-theia` in Theia. ## What it gives you diff --git a/scripts/check-neutral-bundles.mts b/scripts/check-neutral-bundles.mts index 4ed93f49b..2f753c662 100644 --- a/scripts/check-neutral-bundles.mts +++ b/scripts/check-neutral-bundles.mts @@ -132,6 +132,12 @@ const TARGETS = [ // adopter's language module imports it directly on the way into a worker. { name: '@hydranium/core (./lsp)', entry: 'packages/core/lib/lsp/index.js', resolvePackages: [] }, + // The host-neutral GLSP client: what a Theia frontend, a VS Code webview and + // a browser page all load, so a `node:*` import breaks every host at once. + { name: '@hydranium/glsp-client', entry: 'packages/glsp-client/lib/index.js', resolvePackages: [] }, + { name: '@hydranium/glsp-client (./messages)', entry: 'packages/glsp-client/lib/messages/index.js', resolvePackages: [] }, + { name: '@hydranium/glsp-client (./testing)', entry: 'packages/glsp-client/lib/testing/index.js', resolvePackages: [] }, + // The Theia client packages. A Theia frontend IS a browser, so `node:*` here // breaks the form rather than merely bloating it — the same argument the // webview entries make. Each `.` and `./browser` pair is listed because the @@ -177,11 +183,6 @@ const TARGETS = [ resolvePackages: ['@eclipse-glsp/server'] }, { name: '@hydranium/client-theia (./testing)', entry: 'packages/client-theia/lib/testing/index.js', resolvePackages: [] }, - { - name: '@hydranium/glsp-client-theia (./testing)', - entry: 'packages/glsp-client-theia/lib/testing/index.js', - resolvePackages: [] - }, { name: '@hydranium/conformance', entry: 'packages/conformance/lib/index.js', resolvePackages: [] }, // The example's remaining client-side modules, gated module-by-module rather diff --git a/scripts/check-readme-snippet.mts b/scripts/check-readme-snippet.mts index 6cef7b15d..813f4130d 100644 --- a/scripts/check-readme-snippet.mts +++ b/scripts/check-readme-snippet.mts @@ -88,6 +88,10 @@ const SNIPPET_TARGETS = [ readme: 'packages/protocol/src/rpc/README.md', host: 'packages/protocol' }, + { + readme: 'packages/glsp-client/README.md', + host: 'packages/glsp-client' + }, // Every fence here is a skip, so nothing compiles — the entry exists because // discovery requires one, and it will start earning its keep the moment a // compilable fence is added. diff --git a/tsconfig.json b/tsconfig.json index 824870faf..e49d713d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "packages/data-server" }, { "path": "packages/glsp-server" }, { "path": "packages/client-browser" }, + { "path": "packages/glsp-client" }, { "path": "packages/client-theia" }, { "path": "packages/glsp-client-theia" }, { "path": "packages/data-client-theia" },