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" },