From 58d897822fdfa95f11d31b0aefc9f311b945643f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:16:25 +0000 Subject: [PATCH 1/4] Initial plan From 94ecbb2fdd1b42bf351135bda8923f37ad62c543 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:08:00 +0000 Subject: [PATCH 2/4] Add getToken callback to useReplicatedModels with ReplicatedModelParameters parameter Co-authored-by: abstraktor <569215+abstraktor@users.noreply.github.com> --- vue-model-api/src/useReplicatedModels.ts | 119 +++++++++++++++++++---- 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index aa0e89b37e..ebc884ccab 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -1,4 +1,4 @@ -import type { org } from "@modelix/model-client"; +import { org } from "@modelix/model-client"; import type { INodeJS } from "@modelix/ts-model-api"; import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; import type { MaybeRefOrGetter, Ref } from "vue"; @@ -11,6 +11,7 @@ import { handleChange } from "./internal/handleChange"; type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ChangeJS = org.modelix.model.client2.ChangeJS; +type MutableModelTreeJs = org.modelix.model.client2.MutableModelTreeJs; type ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; @@ -29,8 +30,19 @@ function isDefined(value: T | null | undefined): value is T { * * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * - * @param client - Reactive reference of a client to a model server. + * When using the URL string form together with `getToken`, a separate client is created for each + * model so that each model can authenticate with its own token. The `getToken` callback is + * called with the {@link ReplicatedModelParameters} of each model before the connection is + * established, ensuring a fresh token is used even after the parameters change (e.g. a branch + * switch). + * + * @param client - Reactive reference of a client to a model server, or a server URL string when + * used together with `getToken`. * @param models - Reactive reference to an array of ReplicatedModelParameters. + * @param getToken - Optional callback that returns a bearer token for a given + * {@link ReplicatedModelParameters}. When provided together with a server URL as `client`, a + * dedicated client with this token is created for every model. The callback is invoked again + * each time the models change, so a fresh token is always used before connecting. * * @returns {Object} values Wrapper around different returned values. * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. @@ -39,8 +51,11 @@ function isDefined(value: T | null | undefined): value is T { * @returns {Ref} values.error Reactive reference to a connection error. */ export function useReplicatedModels( - client: MaybeRefOrGetter, + client: MaybeRefOrGetter, models: MaybeRefOrGetter, + getToken?: ( + params: ReplicatedModelParameters, + ) => Promise, ): { replicatedModel: Ref; rootNodes: Ref; @@ -59,6 +74,7 @@ export function useReplicatedModels( if (replicatedModel !== null) { replicatedModel.dispose(); } + replicatedModel = null; replicatedModelRef.value = null; rootNodesRef.value = []; errorRef.value = null; @@ -66,12 +82,13 @@ export function useReplicatedModels( useLastPromiseEffect<{ replicatedModel: ReplicatedModelJS; + branches: MutableModelTreeJs[]; cache: Cache; }>( - () => { + async () => { dispose(); - const clientValue = toValue(client); - if (!isDefined(clientValue)) { + const clientOrUrl = toValue(client); + if (!isDefined(clientOrUrl)) { return; } const modelsValue = toValue(models); @@ -79,25 +96,91 @@ export function useReplicatedModels( return; } const cache = new Cache(); - return clientValue + + if (typeof clientOrUrl === "string" && getToken !== undefined) { + // Per-model client mode: each model gets its own dedicated client and token. + // This ensures a fresh token is fetched before connecting, and that separate + // models can use different tokens simultaneously. + const serverUrl = clientOrUrl; + const perModelClients: ClientJS[] = []; + const perModelReplicatedModels: ReplicatedModelJS[] = []; + + for (const params of modelsValue) { + const perModelClient = + await org.modelix.model.client2.connectClient( + serverUrl, + () => getToken(params), + ); + perModelClients.push(perModelClient); + const replicatedModelForParams = + await perModelClient.startReplicatedModel( + params.repositoryId, + params.branchId, + params.idScheme, + ); + perModelReplicatedModels.push(replicatedModelForParams); + } + + const branches = perModelReplicatedModels.map((rm) => rm.getBranch()); + + // Wrap all per-model instances so a single dispose() cleans them all up. + const combinedReplicatedModel: ReplicatedModelJS = { + getBranch: () => branches[0], + dispose: () => { + perModelReplicatedModels.forEach((rm) => rm.dispose()); + perModelClients.forEach((c) => c.dispose()); + }, + getCurrentVersionInformation: () => + perModelReplicatedModels[0].getCurrentVersionInformation(), + getCurrentVersionInformations: () => + Promise.all( + perModelReplicatedModels.map((rm) => + rm.getCurrentVersionInformations(), + ), + ).then((results) => { + const combined: org.modelix.model.client2.VersionInformationJS[] = + []; + for (const result of results) { + combined.push(...Array.from(result)); + } + return combined as unknown as Array; + }), + } as unknown as ReplicatedModelJS; + + return { replicatedModel: combinedReplicatedModel, branches, cache }; + } + + // Standard mode: use the provided ClientJS (existing behaviour). + if (typeof clientOrUrl === "string") { + // URL provided without getToken — cannot create a client without credentials. + return; + } + + return clientOrUrl .startReplicatedModels(modelsValue) - .then((replicatedModel) => ({ replicatedModel, cache })); + .then((connectedReplicatedModel) => ({ + replicatedModel: connectedReplicatedModel, + branches: [connectedReplicatedModel.getBranch()], + cache, + })); }, ( - { replicatedModel: connectedReplicatedModel, cache }, + { replicatedModel: connectedReplicatedModel, branches, cache }, isResultOfLastStartedPromise, ) => { if (isResultOfLastStartedPromise) { replicatedModel = connectedReplicatedModel; - const branch = replicatedModel.getBranch(); - branch.addListener((change: ChangeJS) => { - if (cache === null) { - throw Error("The cache is unexpectedly not set up."); - } - handleChange(change, cache); - }); - const unreactiveRootNodes = branch.getRootNodes(); - const reactiveRootNodes = unreactiveRootNodes.map((node) => + const allRootNodes: INodeJS[] = []; + for (const branch of branches) { + branch.addListener((change: ChangeJS) => { + if (cache === null) { + throw Error("The cache is unexpectedly not set up."); + } + handleChange(change, cache); + }); + allRootNodes.push(...Array.from(branch.getRootNodes())); + } + const reactiveRootNodes = allRootNodes.map((node) => toReactiveINodeJS(node, cache), ); replicatedModelRef.value = replicatedModel; From ee23b4b1c9fdbc0cba069cd1a36b660d88950f74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:28:51 +0000 Subject: [PATCH 3/4] Fix: single shared ClientJS with dynamic token provider for branch switching Co-authored-by: abstraktor <569215+abstraktor@users.noreply.github.com> --- vue-model-api/src/useReplicatedModels.test.ts | 115 +++++++++ vue-model-api/src/useReplicatedModels.ts | 218 ++++++++++-------- 2 files changed, 237 insertions(+), 96 deletions(-) diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts index 92f8842380..d681d9a0bc 100644 --- a/vue-model-api/src/useReplicatedModels.test.ts +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -182,3 +182,118 @@ describe("does not start model", () => { expect(startReplicatedModels).not.toHaveBeenCalled(); }); }); + +describe("URL-based client with getToken", () => { + // Shared mock for startReplicatedModels across all tests in this describe block. + const startReplicatedModels = jest.fn((parameters: ReplicatedModelParameters[]) => { + const branchId = parameters[0]?.branchId ?? "defaultBranch"; + return Promise.resolve( + new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, + ); + }); + + class MockClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + return startReplicatedModels(parameters); + } + } + + let mockClientInstance: MockClientJS; + + // createClient mock — returns the same client instance every call (simulating + // a shared, reused ClientJS per URL). + let createClient: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientInstance = new MockClientJS(); + createClient = jest.fn((_url: string) => + Promise.resolve(mockClientInstance as unknown as ClientJS), + ); + }); + + test("getToken is called with the model params before connecting", async () => { + const params = new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ); + const getToken = jest.fn(() => Promise.resolve("token-for-aBranch")); + + useReplicatedModels("https://model-server/v2", [params], getToken, createClient); + + // Wait for the async client creation and model connection to settle. + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(getToken).toHaveBeenCalledWith(params); + expect(startReplicatedModels).toHaveBeenCalledWith([params]); + }); + + test("the same ClientJS instance is reused when only models change", async () => { + const models = ref([ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + const getToken = jest.fn(() => Promise.resolve("a-token")); + + useReplicatedModels("https://model-server/v2", models, getToken, createClient); + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + // createClient should have been called exactly once for the URL. + expect(createClient).toHaveBeenCalledTimes(1); + + // Switch the branch — this should reuse the existing ClientJS. + models.value = [ + new ReplicatedModelParameters( + "aRepository", + "aNewBranch", + IdSchemeJS.MODELIX, + ), + ]; + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + // Still only one client creation — the existing client is shared. + expect(createClient).toHaveBeenCalledTimes(1); + // getToken should have been called once per connection attempt. + expect(getToken).toHaveBeenCalledTimes(2); + // The second call should carry the new branch params. + expect(getToken).toHaveBeenNthCalledWith( + 2, + new ReplicatedModelParameters("aRepository", "aNewBranch", IdSchemeJS.MODELIX), + ); + }); + + test("a new ClientJS is created when the URL changes", async () => { + const url = ref("https://model-server/v2"); + const getToken = jest.fn(() => Promise.resolve("a-token")); + + useReplicatedModels( + url, + [new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX)], + getToken, + createClient, + ); + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(createClient).toHaveBeenCalledTimes(1); + + url.value = "https://other-server/v2"; + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(createClient).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenLastCalledWith( + "https://other-server/v2", + expect.any(Function), + ); + }); +}); diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index ebc884ccab..365ddc38cc 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -8,10 +8,11 @@ import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; import { handleChange } from "./internal/handleChange"; +const { connectClient } = org.modelix.model.client2; + type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; type ChangeJS = org.modelix.model.client2.ChangeJS; -type MutableModelTreeJs = org.modelix.model.client2.MutableModelTreeJs; type ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; @@ -30,19 +31,22 @@ function isDefined(value: T | null | undefined): value is T { * * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * - * When using the URL string form together with `getToken`, a separate client is created for each - * model so that each model can authenticate with its own token. The `getToken` callback is - * called with the {@link ReplicatedModelParameters} of each model before the connection is - * established, ensuring a fresh token is used even after the parameters change (e.g. a branch - * switch). + * When a server URL string is passed as `client`, a single {@link ClientJS} is created and + * **shared across all models and across branch switches**. This preserves the version cache so + * that switching branches only fetches the delta. If `getToken` is also supplied, it is called + * with the first model's {@link ReplicatedModelParameters} before every call to + * {@link ClientJS.startReplicatedModels}, ensuring a fresh token is used even after the + * parameters change (e.g. a branch switch). The token is provided to the underlying HTTP client + * as a dynamic bearer-token callback, so it is also picked up by the Ktor refresh flow on 401. * - * @param client - Reactive reference of a client to a model server, or a server URL string when - * used together with `getToken`. - * @param models - Reactive reference to an array of ReplicatedModelParameters. + * @param client - Reactive reference of a client to a model server, or a server URL string. + * When a URL string is provided a {@link ClientJS} is created internally and reused until the + * URL changes. + * @param models - Reactive reference to an array of {@link ReplicatedModelParameters}. * @param getToken - Optional callback that returns a bearer token for a given - * {@link ReplicatedModelParameters}. When provided together with a server URL as `client`, a - * dedicated client with this token is created for every model. The callback is invoked again - * each time the models change, so a fresh token is always used before connecting. + * {@link ReplicatedModelParameters}. Only used when `client` is a URL string. Called before + * each connection attempt so the token is always fresh. + * @param createClient - Internal seam for testing; defaults to {@link connectClient}. * * @returns {Object} values Wrapper around different returned values. * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. @@ -53,24 +57,42 @@ function isDefined(value: T | null | undefined): value is T { export function useReplicatedModels( client: MaybeRefOrGetter, models: MaybeRefOrGetter, - getToken?: ( - params: ReplicatedModelParameters, - ) => Promise, + getToken?: (params: ReplicatedModelParameters) => Promise, + createClient: ( + url: string, + tokenProvider?: () => Promise, + ) => Promise = connectClient, ): { replicatedModel: Ref; rootNodes: Ref; dispose: () => void; error: Ref; } { - // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. + // --------------------------------------------------------------------------- + // URL-based client state + // A single ClientJS is kept per URL and reused across model changes so that + // the version cache is preserved (branch switches only fetch deltas). + // --------------------------------------------------------------------------- + const urlClientRef: Ref = shallowRef(null); + + // The token provider reads this mutable variable so every Ktor request + // automatically uses the token for the currently active models. + let currentModelsForToken: ReplicatedModelParameters[] = []; + const tokenProvider = (): Promise => { + if (currentModelsForToken.length === 0) return Promise.resolve(null); + return getToken!(currentModelsForToken[0]); + }; + + // --------------------------------------------------------------------------- + // Replicated-model state + // --------------------------------------------------------------------------- + // Use a plain variable (not a ref) to avoid Vue reactivity overhead on writes. let replicatedModel: ReplicatedModelJS | null = null; const replicatedModelRef: Ref = shallowRef(null); const rootNodesRef: Ref = shallowRef([]); const errorRef: Ref = shallowRef(null); - const dispose = () => { - // Using `replicatedModelRef.value` here would create a circular dependency. - // `toRaw` does not work on `Ref<>`. + const disposeReplicatedModel = () => { if (replicatedModel !== null) { replicatedModel.dispose(); } @@ -80,107 +102,111 @@ export function useReplicatedModels( errorRef.value = null; }; + const dispose = () => { + disposeReplicatedModel(); + if (urlClientRef.value !== null) { + urlClientRef.value.dispose(); + urlClientRef.value = null; + } + }; + + // --------------------------------------------------------------------------- + // Effect 1: manage the URL → ClientJS lifecycle. + // Re-runs when `client` changes. When the URL changes the old ClientJS is + // disposed and a new one is created (with the same token-provider closure). + // --------------------------------------------------------------------------- + useLastPromiseEffect( + () => { + // Dispose old URL client (the replicated model will be cleaned up by + // effect 2 when it observes urlClientRef becoming null). + if (urlClientRef.value !== null) { + urlClientRef.value.dispose(); + urlClientRef.value = null; + } + + const clientOrUrl = toValue(client); + if (!isDefined(clientOrUrl) || typeof clientOrUrl !== "string") { + return; + } + + return createClient( + clientOrUrl, + getToken !== undefined ? tokenProvider : undefined, + ); + }, + (createdClient, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + urlClientRef.value = createdClient; + } else { + // A newer effect superseded this one; discard the client. + createdClient.dispose(); + } + }, + (reason, isResultOfLastStartedPromise) => { + if (isResultOfLastStartedPromise) { + errorRef.value = reason; + } + }, + ); + + // --------------------------------------------------------------------------- + // Effect 2: connect/reconnect the replicated model. + // Re-runs when `client`, `models`, or `urlClientRef` (the resolved URL + // client) change. + // --------------------------------------------------------------------------- useLastPromiseEffect<{ replicatedModel: ReplicatedModelJS; - branches: MutableModelTreeJs[]; cache: Cache; }>( - async () => { - dispose(); + () => { + disposeReplicatedModel(); + const clientOrUrl = toValue(client); if (!isDefined(clientOrUrl)) { return; } + const modelsValue = toValue(models); if (!isDefined(modelsValue)) { return; } - const cache = new Cache(); - if (typeof clientOrUrl === "string" && getToken !== undefined) { - // Per-model client mode: each model gets its own dedicated client and token. - // This ensures a fresh token is fetched before connecting, and that separate - // models can use different tokens simultaneously. - const serverUrl = clientOrUrl; - const perModelClients: ClientJS[] = []; - const perModelReplicatedModels: ReplicatedModelJS[] = []; - - for (const params of modelsValue) { - const perModelClient = - await org.modelix.model.client2.connectClient( - serverUrl, - () => getToken(params), - ); - perModelClients.push(perModelClient); - const replicatedModelForParams = - await perModelClient.startReplicatedModel( - params.repositoryId, - params.branchId, - params.idScheme, - ); - perModelReplicatedModels.push(replicatedModelForParams); - } + let resolvedClient: ClientJS; - const branches = perModelReplicatedModels.map((rm) => rm.getBranch()); - - // Wrap all per-model instances so a single dispose() cleans them all up. - const combinedReplicatedModel: ReplicatedModelJS = { - getBranch: () => branches[0], - dispose: () => { - perModelReplicatedModels.forEach((rm) => rm.dispose()); - perModelClients.forEach((c) => c.dispose()); - }, - getCurrentVersionInformation: () => - perModelReplicatedModels[0].getCurrentVersionInformation(), - getCurrentVersionInformations: () => - Promise.all( - perModelReplicatedModels.map((rm) => - rm.getCurrentVersionInformations(), - ), - ).then((results) => { - const combined: org.modelix.model.client2.VersionInformationJS[] = - []; - for (const result of results) { - combined.push(...Array.from(result)); - } - return combined as unknown as Array; - }), - } as unknown as ReplicatedModelJS; - - return { replicatedModel: combinedReplicatedModel, branches, cache }; - } - - // Standard mode: use the provided ClientJS (existing behaviour). if (typeof clientOrUrl === "string") { - // URL provided without getToken — cannot create a client without credentials. - return; + // Reactive read — re-runs this effect when the URL client becomes ready. + const urlClient = urlClientRef.value; + if (urlClient === null) { + return; // Client not yet ready; will re-run once effect 1 resolves. + } + // Update the token context so the provider returns the right token for + // these models on every subsequent Ktor request. + currentModelsForToken = modelsValue; + resolvedClient = urlClient; + } else { + resolvedClient = clientOrUrl; } - return clientOrUrl + const cache = new Cache(); + return resolvedClient .startReplicatedModels(modelsValue) - .then((connectedReplicatedModel) => ({ - replicatedModel: connectedReplicatedModel, - branches: [connectedReplicatedModel.getBranch()], - cache, - })); + .then((rm) => ({ replicatedModel: rm, cache })); }, ( - { replicatedModel: connectedReplicatedModel, branches, cache }, + { replicatedModel: connectedReplicatedModel, cache }, isResultOfLastStartedPromise, ) => { if (isResultOfLastStartedPromise) { replicatedModel = connectedReplicatedModel; - const allRootNodes: INodeJS[] = []; - for (const branch of branches) { - branch.addListener((change: ChangeJS) => { - if (cache === null) { - throw Error("The cache is unexpectedly not set up."); - } - handleChange(change, cache); - }); - allRootNodes.push(...Array.from(branch.getRootNodes())); - } - const reactiveRootNodes = allRootNodes.map((node) => + const branch = replicatedModel.getBranch(); + branch.addListener((change: ChangeJS) => { + if (cache === null) { + throw Error("The cache is unexpectedly not set up."); + } + handleChange(change, cache); + }); + const unreactiveRootNodes = branch.getRootNodes(); + const reactiveRootNodes = unreactiveRootNodes.map((node) => toReactiveINodeJS(node, cache), ); replicatedModelRef.value = replicatedModel; From 98d22282ea261db82094416146e857722c50359a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:48:37 +0000 Subject: [PATCH 4/4] Add per-binding token support to ClientJS and simplify useReplicatedModels Co-authored-by: abstraktor <569215+abstraktor@users.noreply.github.com> --- .../org/modelix/model/client2/ClientJS.kt | 122 ++++++++++++++-- .../org/modelix/model/client2/ClientJSTest.kt | 14 ++ vue-model-api/src/useModelClient.ts | 11 +- vue-model-api/src/useReplicatedModels.test.ts | 33 +++-- vue-model-api/src/useReplicatedModels.ts | 135 +++++++----------- 5 files changed, 208 insertions(+), 107 deletions(-) diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt index ed263c40d4..948ab220dc 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt @@ -4,6 +4,11 @@ package org.modelix.model.client2 import INodeJS import INodeReferenceJS +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.http.HttpHeaders import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.await @@ -41,8 +46,70 @@ import kotlin.js.Promise import kotlin.time.Duration.Companion.seconds /** - * Same as [loadModelsFromJsonAsBranch] but directly returns the [MutableModelTreeJs.rootNode] of the created branch. + * Extracts the binding key from a request URL path. + * + * Binding keys have the form `{repositoryId}/{branchId}` and are extracted from the path pattern + * `/repositories/{repositoryId}/branches/{branchId}`. + * + * Returns `null` for requests that are not associated with a specific branch (e.g. `/v2/server-id`). + */ +internal fun extractBindingKey(path: String): String? { + val match = Regex("/repositories/([^/]+)/branches/([^/]+)").find(path) ?: return null + return "${match.groupValues[1]}/${match.groupValues[2]}" +} + +/** Configuration for [PerBindingAuthPlugin]. */ +internal class PerBindingAuthConfig { + var getToken: suspend (bindingKey: String?) -> String? = { null } +} + +/** + * Custom Ktor client plugin that adds `Authorization: Bearer ` headers per branch binding. + * + * For requests that target a specific repository/branch, the token is looked up by binding key. + * For other requests (e.g. `/v2/server-id`), the global token is used as a fallback. + * The token provider is invoked on **every** request, ensuring tokens are always fresh without + * relying on Ktor's internal bearer-token cache. */ +internal val PerBindingAuthPlugin = createClientPlugin("PerBindingAuth", ::PerBindingAuthConfig) { + onRequest { request, _ -> + val bindingKey = extractBindingKey(request.url.encodedPath) + val token = pluginConfig.getToken(bindingKey) + if (token != null) { + request.headers[HttpHeaders.Authorization] = "Bearer $token" + } + } +} + +/** + * A [ModelClientV2Builder] that installs [PerBindingAuthPlugin] to support per-binding tokens. + * + * The [tokenSelector] is called for every HTTP request. It receives the binding key + * (`{repositoryId}/{branchId}`) extracted from the request URL, or `null` for non-binding + * requests. The implementation should return the appropriate bearer token or `null` for + * unauthenticated requests. + */ +private class ClientJSInternalBuilder( + private val tokenSelector: suspend (bindingKey: String?) -> String?, +) : ModelClientV2Builder() { + override fun createHttpClient(): HttpClient { + return HttpClient(Js) { + configureHttpClient(this) + } + } + + override fun configureHttpClient(config: HttpClientConfig<*>) { + // Install the standard plugins (JSON, timeout, retry, compression). + // authConfig is intentionally kept null so ModelixAuthClient is NOT installed; + // authentication is handled exclusively by PerBindingAuthPlugin below. + super.configureHttpClient(config) + config.install(PerBindingAuthPlugin) { + getToken = tokenSelector + } + } +} + + @JsExport fun loadModelsFromJson(json: Array): INodeJS { val branch = loadModelsFromJsonAsBranch(json) @@ -70,19 +137,28 @@ fun loadModelsFromJsonAsBranch(json: Array): MutableModelTreeJs { * * @param url URL to the V2 endpoint of the model server. * e.g., http://localhost:28102/v2 + * @param bearerTokenProvider Optional global token provider used for requests that are not + * associated with a specific model binding (e.g. `/v2/server-id`). Per-binding tokens take + * precedence over this provider and can be supplied via + * [ReplicatedModelParameters.tokenProvider] when calling [ClientJS.startReplicatedModels]. */ @JsExport fun connectClient(url: String, bearerTokenProvider: (() -> Promise)? = null): Promise { return GlobalScope.promise { - val clientBuilder = ModelClientV2.builder() - .url(url) + val bindingTokenProviders = mutableMapOf String?>() + val globalToken: (suspend () -> String?)? = bearerTokenProvider?.let { bp -> { bp().await() } } + + val clientBuilder = ClientJSInternalBuilder { bindingKey -> + if (bindingKey != null) { + bindingTokenProviders[bindingKey]?.invoke() ?: globalToken?.invoke() + } else { + globalToken?.invoke() + } + }.url(url) - if (bearerTokenProvider != null) { - clientBuilder.authToken { bearerTokenProvider().await() } - } val client = clientBuilder.build() client.init() - return@promise ClientJSImpl(client) + return@promise ClientJSImpl(client, bindingTokenProviders) } } @@ -207,13 +283,33 @@ interface ClientJS { } @JsExport -data class ReplicatedModelParameters( +class ReplicatedModelParameters( val repositoryId: String, val branchId: String, val idScheme: IdSchemeJS, + /** + * Optional bearer-token provider for this specific binding. + * + * When provided, this callback is invoked on **every individual HTTP request** + * (each GET, POST, etc.) that targets the `repositoryId`/`branchId` combination — + * not just once per [startReplicatedModels] call. This ensures a fresh token is used + * for each request without relying on Ktor's internal bearer-token cache. It takes + * precedence over the global token provider supplied to [connectClient]. + * + * Pass `null` (or omit this parameter) to use the global token from [connectClient]. + */ + val tokenProvider: (() -> Promise)? = null, ) -internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { +internal class ClientJSImpl( + private val modelClient: ModelClientV2, + /** + * Mutable map shared with [connectClient]'s [PerBindingAuthPlugin] token selector. + * Entries are added here when [startReplicatedModels] is called with per-binding token + * providers, making the plugin immediately route the right token for each branch's requests. + */ + private val bindingTokenProviders: MutableMap String?> = mutableMapOf(), +) : ClientJS { override fun setClientProvidedUserId(userId: String) { modelClient.setClientProvidedUserId(userId) @@ -288,6 +384,14 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { override fun startReplicatedModels(parameters: Array): Promise { return GlobalScope.promise { val models = parameters.map { parameters -> + // Register a per-binding token provider if one was supplied with the parameters. + // The shared bindingTokenProviders map is read by PerBindingAuthPlugin on every + // HTTP request, so the registration takes effect immediately. + parameters.tokenProvider?.let { jsProvider -> + val key = "${parameters.repositoryId}/${parameters.branchId}" + bindingTokenProviders[key] = { jsProvider().await() } + } + val modelClient = modelClient val branchReference = RepositoryId(parameters.repositoryId).getBranchReference(parameters.branchId) val idGenerator: (TreeId) -> INodeIdGenerator = when (parameters.idScheme) { diff --git a/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt index 5aa69ba526..ee9eb687c5 100644 --- a/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt +++ b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt @@ -3,6 +3,7 @@ package org.modelix.model.client2 import GeneratedConcept import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ClientJSTest { @@ -57,4 +58,17 @@ class ClientJSTest { // Assert assertEquals(child0.getReferenceTargetNode("aReference"), child1) } + + @Test + fun extractBindingKey_returnsKeyForBranchRequest() { + val key = extractBindingKey("/v2/repositories/myRepo/branches/main/delta") + assertEquals("myRepo/main", key) + } + + @Test + fun extractBindingKey_returnsNullForNonBranchRequest() { + assertNull(extractBindingKey("/v2/server-id")) + assertNull(extractBindingKey("/v2/client-id")) + assertNull(extractBindingKey("/v2/repositories/myRepo")) + } } diff --git a/vue-model-api/src/useModelClient.ts b/vue-model-api/src/useModelClient.ts index 5190d01506..d1212967e1 100644 --- a/vue-model-api/src/useModelClient.ts +++ b/vue-model-api/src/useModelClient.ts @@ -11,9 +11,10 @@ type ClientJS = org.modelix.model.client2.ClientJS; * Creates a model client for a given URL. * * The URL is reactive and if it changes, the client is automatically disposed and a new client for the updated URL is created. + * If the URL is `null` or `undefined`, no client is created. * - * @param url - Reactive reference of an URL to a model server. - * @param getClient - Function how to create a cliente given an URL. + * @param url - Reactive reference of a URL to a model server. When `null` or `undefined`, no client is created. + * @param getClient - Function how to create a client given an URL. * * Defaults to connecting directly to the modelix model server under the given URL. * @@ -23,7 +24,7 @@ type ClientJS = org.modelix.model.client2.ClientJS; * @returns {Ref} values.error Reactive reference to a client connection error. */ export function useModelClient( - url: MaybeRefOrGetter, + url: MaybeRefOrGetter, getClient: (url: string) => Promise = connectClient, ): { client: Ref; @@ -44,7 +45,9 @@ export function useModelClient( useLastPromiseEffect( () => { dispose(); - return getClient(toValue(url)); + const urlValue = toValue(url); + if (!urlValue) return undefined; + return getClient(urlValue); }, (createdClient, isResultOfLastStartedPromise) => { if (isResultOfLastStartedPromise) { diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts index d681d9a0bc..a294df5afa 100644 --- a/vue-model-api/src/useReplicatedModels.test.ts +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -214,7 +214,7 @@ describe("URL-based client with getToken", () => { ); }); - test("getToken is called with the model params before connecting", async () => { + test("each model param gets its own tokenProvider wrapping getToken", async () => { const params = new ReplicatedModelParameters( "aRepository", "aBranch", @@ -228,8 +228,17 @@ describe("URL-based client with getToken", () => { await new Promise(process.nextTick); await new Promise(process.nextTick); + expect(startReplicatedModels).toHaveBeenCalledTimes(1); + const calledWith = startReplicatedModels.mock.calls[0][0]; + + // Each param should have tokenProvider set. + expect(typeof calledWith[0].tokenProvider).toBe("function"); + expect(calledWith[0].repositoryId).toBe("aRepository"); + expect(calledWith[0].branchId).toBe("aBranch"); + + // Invoking tokenProvider should delegate to getToken with the original param. + await calledWith[0].tokenProvider(); expect(getToken).toHaveBeenCalledWith(params); - expect(startReplicatedModels).toHaveBeenCalledWith([params]); }); test("the same ClientJS instance is reused when only models change", async () => { @@ -245,6 +254,7 @@ describe("URL-based client with getToken", () => { // createClient should have been called exactly once for the URL. expect(createClient).toHaveBeenCalledTimes(1); + expect(startReplicatedModels).toHaveBeenCalledTimes(1); // Switch the branch — this should reuse the existing ClientJS. models.value = [ @@ -260,13 +270,12 @@ describe("URL-based client with getToken", () => { // Still only one client creation — the existing client is shared. expect(createClient).toHaveBeenCalledTimes(1); - // getToken should have been called once per connection attempt. - expect(getToken).toHaveBeenCalledTimes(2); - // The second call should carry the new branch params. - expect(getToken).toHaveBeenNthCalledWith( - 2, - new ReplicatedModelParameters("aRepository", "aNewBranch", IdSchemeJS.MODELIX), - ); + // startReplicatedModels should be called again for the new branch. + expect(startReplicatedModels).toHaveBeenCalledTimes(2); + // The second call should carry the new branch params with tokenProvider. + const secondCallParams = startReplicatedModels.mock.calls[1][0]; + expect(secondCallParams[0].branchId).toBe("aNewBranch"); + expect(typeof secondCallParams[0].tokenProvider).toBe("function"); }); test("a new ClientJS is created when the URL changes", async () => { @@ -284,6 +293,7 @@ describe("URL-based client with getToken", () => { await new Promise(process.nextTick); expect(createClient).toHaveBeenCalledTimes(1); + expect(createClient).toHaveBeenCalledWith("https://model-server/v2"); url.value = "https://other-server/v2"; @@ -291,9 +301,6 @@ describe("URL-based client with getToken", () => { await new Promise(process.nextTick); expect(createClient).toHaveBeenCalledTimes(2); - expect(createClient).toHaveBeenLastCalledWith( - "https://other-server/v2", - expect.any(Function), - ); + expect(createClient).toHaveBeenNthCalledWith(2, "https://other-server/v2"); }); }); diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index 365ddc38cc..e6cc12a93d 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -7,8 +7,7 @@ import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; import { handleChange } from "./internal/handleChange"; - -const { connectClient } = org.modelix.model.client2; +import { useModelClient } from "./useModelClient"; type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; @@ -16,6 +15,9 @@ type ChangeJS = org.modelix.model.client2.ChangeJS; type ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; +const { ReplicatedModelParameters: ReplicatedModelParametersCtor } = + org.modelix.model.client2; + function isDefined(value: T | null | undefined): value is T { return value !== null && value !== undefined; } @@ -32,21 +34,24 @@ function isDefined(value: T | null | undefined): value is T { * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * * When a server URL string is passed as `client`, a single {@link ClientJS} is created and - * **shared across all models and across branch switches**. This preserves the version cache so - * that switching branches only fetches the delta. If `getToken` is also supplied, it is called - * with the first model's {@link ReplicatedModelParameters} before every call to - * {@link ClientJS.startReplicatedModels}, ensuring a fresh token is used even after the - * parameters change (e.g. a branch switch). The token is provided to the underlying HTTP client - * as a dynamic bearer-token callback, so it is also picked up by the Ktor refresh flow on 401. + * **shared across all models and across branch switches** via {@link useModelClient}. This + * preserves the version cache so that switching branches only fetches the delta, and cross-model + * references continue to resolve correctly. + * + * If `getToken` is also supplied, a fresh token is obtained by calling `getToken(params)` before + * every HTTP request for that binding via {@link ReplicatedModelParameters.tokenProvider}. Each + * model in the `models` array receives an independent token provider, so concurrent connections + * to different repositories or branches each use their own credentials. * * @param client - Reactive reference of a client to a model server, or a server URL string. - * When a URL string is provided a {@link ClientJS} is created internally and reused until the - * URL changes. + * When a URL string is provided a {@link ClientJS} is created internally via + * {@link useModelClient} and reused until the URL changes. * @param models - Reactive reference to an array of {@link ReplicatedModelParameters}. * @param getToken - Optional callback that returns a bearer token for a given - * {@link ReplicatedModelParameters}. Only used when `client` is a URL string. Called before - * each connection attempt so the token is always fresh. - * @param createClient - Internal seam for testing; defaults to {@link connectClient}. + * {@link ReplicatedModelParameters}. Only used when `client` is a URL string. Called on every + * HTTP request for the corresponding binding so the token is always fresh. + * @param createClient - Internal seam for testing; defaults to {@link connectClient} via + * {@link useModelClient}. * * @returns {Object} values Wrapper around different returned values. * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. @@ -58,10 +63,7 @@ export function useReplicatedModels( client: MaybeRefOrGetter, models: MaybeRefOrGetter, getToken?: (params: ReplicatedModelParameters) => Promise, - createClient: ( - url: string, - tokenProvider?: () => Promise, - ) => Promise = connectClient, + createClient?: (url: string) => Promise, ): { replicatedModel: Ref; rootNodes: Ref; @@ -69,24 +71,24 @@ export function useReplicatedModels( error: Ref; } { // --------------------------------------------------------------------------- - // URL-based client state - // A single ClientJS is kept per URL and reused across model changes so that - // the version cache is preserved (branch switches only fetch deltas). + // URL-based client lifecycle (mirrors useModelClient internally). + // When `client` is a URL string, useModelClient creates and disposes the + // ClientJS automatically when the URL changes — preserving the version cache + // across branch switches and enabling cross-model reference resolution. + // When `client` is a ClientJS, the getter always returns null so no + // extra client is managed here. // --------------------------------------------------------------------------- - const urlClientRef: Ref = shallowRef(null); - - // The token provider reads this mutable variable so every Ktor request - // automatically uses the token for the currently active models. - let currentModelsForToken: ReplicatedModelParameters[] = []; - const tokenProvider = (): Promise => { - if (currentModelsForToken.length === 0) return Promise.resolve(null); - return getToken!(currentModelsForToken[0]); - }; + const { client: urlClientRef, dispose: disposeUrlClient } = useModelClient( + () => { + const c = toValue(client); + return typeof c === "string" ? c : null; + }, + createClient, + ); // --------------------------------------------------------------------------- // Replicated-model state // --------------------------------------------------------------------------- - // Use a plain variable (not a ref) to avoid Vue reactivity overhead on writes. let replicatedModel: ReplicatedModelJS | null = null; const replicatedModelRef: Ref = shallowRef(null); const rootNodesRef: Ref = shallowRef([]); @@ -104,55 +106,13 @@ export function useReplicatedModels( const dispose = () => { disposeReplicatedModel(); - if (urlClientRef.value !== null) { - urlClientRef.value.dispose(); - urlClientRef.value = null; - } + disposeUrlClient(); }; // --------------------------------------------------------------------------- - // Effect 1: manage the URL → ClientJS lifecycle. - // Re-runs when `client` changes. When the URL changes the old ClientJS is - // disposed and a new one is created (with the same token-provider closure). - // --------------------------------------------------------------------------- - useLastPromiseEffect( - () => { - // Dispose old URL client (the replicated model will be cleaned up by - // effect 2 when it observes urlClientRef becoming null). - if (urlClientRef.value !== null) { - urlClientRef.value.dispose(); - urlClientRef.value = null; - } - - const clientOrUrl = toValue(client); - if (!isDefined(clientOrUrl) || typeof clientOrUrl !== "string") { - return; - } - - return createClient( - clientOrUrl, - getToken !== undefined ? tokenProvider : undefined, - ); - }, - (createdClient, isResultOfLastStartedPromise) => { - if (isResultOfLastStartedPromise) { - urlClientRef.value = createdClient; - } else { - // A newer effect superseded this one; discard the client. - createdClient.dispose(); - } - }, - (reason, isResultOfLastStartedPromise) => { - if (isResultOfLastStartedPromise) { - errorRef.value = reason; - } - }, - ); - - // --------------------------------------------------------------------------- - // Effect 2: connect/reconnect the replicated model. - // Re-runs when `client`, `models`, or `urlClientRef` (the resolved URL - // client) change. + // Effect: connect/reconnect when client or models change. + // Re-runs whenever `client`, `urlClientRef` (the resolved URL client), or + // `models` changes. // --------------------------------------------------------------------------- useLastPromiseEffect<{ replicatedModel: ReplicatedModelJS; @@ -177,19 +137,32 @@ export function useReplicatedModels( // Reactive read — re-runs this effect when the URL client becomes ready. const urlClient = urlClientRef.value; if (urlClient === null) { - return; // Client not yet ready; will re-run once effect 1 resolves. + return; // Client not yet ready; will re-run once useModelClient resolves. } - // Update the token context so the provider returns the right token for - // these models on every subsequent Ktor request. - currentModelsForToken = modelsValue; resolvedClient = urlClient; } else { resolvedClient = clientOrUrl; } + // Attach per-binding token providers. Each model receives an independent + // provider callback so concurrent connections to different branches each + // use their own fresh credentials. + const paramsWithTokens = + getToken !== undefined + ? modelsValue.map( + (p) => + new ReplicatedModelParametersCtor( + p.repositoryId, + p.branchId, + p.idScheme, + () => getToken(p), + ), + ) + : modelsValue; + const cache = new Cache(); return resolvedClient - .startReplicatedModels(modelsValue) + .startReplicatedModels(paramsWithTokens) .then((rm) => ({ replicatedModel: rm, cache })); }, (