Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions frontend/apps/artcraft/app/src/api/StorytellerApiHostSync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { createStorytellerApiHostSync } from "./StorytellerApiHostSync";

describe("createStorytellerApiHostSync", () => {
it("installs the native host before resolving", async () => {
let currentHost = "https://api.example.test";
const sync = createStorytellerApiHostSync({
getNativeHost: async () => "http://localhost:12345",
getCurrentHost: () => currentHost,
setCurrentHost: (host) => {
currentHost = host;
},
});

await expect(sync()).resolves.toEqual({
host: "http://localhost:12345",
changed: true,
source: "native",
});
expect(currentHost).toBe("http://localhost:12345");
});

it("shares one native request between concurrent callers", async () => {
let resolveHost!: (host: string) => void;
const getNativeHost = jest.fn(
() =>
new Promise<string>((resolve) => {
resolveHost = resolve;
}),
);
const sync = createStorytellerApiHostSync({
getNativeHost,
getCurrentHost: () => "https://api.example.test",
setCurrentHost: () => undefined,
});

const first = sync();
const second = sync();
expect(second).toBe(first);
expect(getNativeHost).toHaveBeenCalledTimes(1);

resolveHost("https://native.example.test");
await expect(first).resolves.toMatchObject({
host: "https://native.example.test",
source: "native",
});
});

it("uses a successful cached host without repeating a changed result", async () => {
let now = 100;
let currentHost = "https://api.example.test";
const getNativeHost = jest.fn(async () => "https://native.example.test");
const sync = createStorytellerApiHostSync({
getNativeHost,
getCurrentHost: () => currentHost,
setCurrentHost: (host) => {
currentHost = host;
},
now: () => now,
syncThresholdMs: 10,
});

await expect(sync()).resolves.toMatchObject({
changed: true,
source: "native",
});
now = 105;
await expect(sync()).resolves.toEqual({
host: "https://native.example.test",
changed: false,
source: "cache",
});
expect(getNativeHost).toHaveBeenCalledTimes(1);

now = 111;
await expect(sync()).resolves.toEqual({
host: "https://native.example.test",
changed: false,
source: "native",
});
expect(getNativeHost).toHaveBeenCalledTimes(2);
});

it("retries after a native failure", async () => {
const getNativeHost = jest
.fn<Promise<string>, []>()
.mockRejectedValueOnce(new Error("native unavailable"))
.mockResolvedValueOnce("https://native.example.test");
const sync = createStorytellerApiHostSync({
getNativeHost,
getCurrentHost: () => "https://api.example.test",
setCurrentHost: () => undefined,
});

await expect(sync()).rejects.toThrow("native unavailable");
await expect(sync()).resolves.toMatchObject({
host: "https://native.example.test",
});
expect(getNativeHost).toHaveBeenCalledTimes(2);
});

it("does not trust the cache when another caller changed the host store", async () => {
let currentHost = "https://api.example.test";
const getNativeHost = jest.fn(async () => "https://native.example.test");
const sync = createStorytellerApiHostSync({
getNativeHost,
getCurrentHost: () => currentHost,
setCurrentHost: (host) => {
currentHost = host;
},
});

await sync();
currentHost = "https://other.example.test";
await expect(sync()).resolves.toMatchObject({
changed: true,
source: "native",
});
expect(getNativeHost).toHaveBeenCalledTimes(2);
expect(currentHost).toBe("https://native.example.test");
});

it.each([undefined, null, "", " "])(
"rejects a missing native host (%p)",
async (nativeHost) => {
const setCurrentHost = jest.fn();
const sync = createStorytellerApiHostSync({
getNativeHost: async () => nativeHost,
getCurrentHost: () => "https://api.example.test",
setCurrentHost,
});

await expect(sync()).rejects.toThrow(
"Tauri app info did not provide a Storyteller API host",
);
expect(setCurrentHost).not.toHaveBeenCalled();
},
);

it("does not cache a host rejected by the host store", async () => {
const getNativeHost = jest.fn(async () => "not-a-url");
const sync = createStorytellerApiHostSync({
getNativeHost,
getCurrentHost: () => "https://api.example.test",
setCurrentHost: () => {
throw new Error("invalid host");
},
});

await expect(sync()).rejects.toThrow("invalid host");
await expect(sync()).rejects.toThrow("invalid host");
expect(getNativeHost).toHaveBeenCalledTimes(2);
});
});
71 changes: 71 additions & 0 deletions frontend/apps/artcraft/app/src/api/StorytellerApiHostSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export interface ApiHostSyncResult {
host: string;
changed: boolean;
source: "native" | "cache";
}

export interface ApiHostSyncDependencies {
getNativeHost: () => Promise<string | null | undefined>;
getCurrentHost: () => string;
setCurrentHost: (host: string) => void;
now?: () => number;
syncThresholdMs?: number;
}

/**
* Creates a small synchronization coordinator for the native-configured API
* host. Concurrent callers share the same native request, while a failed
* request remains retryable.
*/
export const createStorytellerApiHostSync = ({
getNativeHost,
getCurrentHost,
setCurrentHost,
now = Date.now,
syncThresholdMs = 10_000,
}: ApiHostSyncDependencies): (() => Promise<ApiHostSyncResult>) => {
let inFlight: Promise<ApiHostSyncResult> | undefined;
let lastSuccessAt: number | undefined;
let lastHost: string | undefined;

return () => {
if (inFlight) {
return inFlight;
}

if (
lastSuccessAt !== undefined &&
lastHost !== undefined &&
getCurrentHost() === lastHost &&
now() - lastSuccessAt <= syncThresholdMs
) {
return Promise.resolve({
host: lastHost,
changed: false,
source: "cache",
});
}

const request = (async (): Promise<ApiHostSyncResult> => {
const nativeHost = (await getNativeHost())?.trim();
if (!nativeHost) {
throw new Error(
"Tauri app info did not provide a Storyteller API host",
);
}

const changed = getCurrentHost() !== nativeHost;
setCurrentHost(nativeHost);
lastHost = nativeHost;
lastSuccessAt = now();

return { host: nativeHost, changed, source: "native" };
})();
const sharedRequest = request.finally(() => {
inFlight = undefined;
});
inFlight = sharedRequest;

return sharedRequest;
};
};
80 changes: 20 additions & 60 deletions frontend/apps/artcraft/app/src/api/SyncStorytellerApiConfig.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,30 @@
import { GetAppInfo } from "@storyteller/tauri-api";
import { StorytellerApiHostStore } from "@storyteller/api";
import { forceGetUserInfoAndSubcriptions } from "~/signals";
import { createStorytellerApiHostSync } from "./StorytellerApiHostSync";
import type { ApiHostSyncResult } from "./StorytellerApiHostSync";

// Time before we should call Tauri again.
const SYNC_THRESHOLD = 10 * 1000;
const hostStore = StorytellerApiHostStore.getInstance();

const syncStorytellerApiHost = createStorytellerApiHostSync({
getNativeHost: async () => (await GetAppInfo()).payload.storyteller_host,
getCurrentHost: () => hostStore.getApiSchemeAndHost(),
setCurrentHost: (host) => hostStore.setApiSchemeAndHost(host),
});

/**
* Keep track of if we can call Tauri again.
* Installs the native-configured API host. The returned promise resolves only
* after the host store has been updated, so callers can safely mount REST
* consumers afterward.
*/
class Cache {
private static instance: Cache;
private lastFetchSuccess?: number;

public static getInstance(): Cache {
if (Cache.instance !== undefined) {
return Cache.instance;
}
const instance = new Cache();
Cache.instance = instance;
return instance;
}

public canCall() : boolean {
if (this.lastFetchSuccess === undefined) {
return true;
}
return Date.now() - this.lastFetchSuccess > SYNC_THRESHOLD;
}

public setCallSuccess() {
this.lastFetchSuccess = Date.now();
}
}
export const SyncStorytellerApiConfig = (): Promise<ApiHostSyncResult> =>
syncStorytellerApiHost();

/**
* Syncs our view of the Storyteller API configs with Tauri.
* Runs any necessary user session functions if things change.
* Refreshes session state after a host change. Startup treats this as
* noncritical: a session/network failure must not undo a successfully
* installed API host or prevent the application shell from rendering.
*/
export const SyncStorytellerApiConfig = async () => {
console.log("SyncStorytellerApiConfig()")

const cache = Cache.getInstance();
const oldValue = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();

if (!cache.canCall()) {
return;
}

GetAppInfo().then(async (appInfo) => {
console.log("SyncStorytellerApiConfig() - appInfo", appInfo);

const schemeAndHost = appInfo.payload.storyteller_host;
if (!schemeAndHost) {
return ;
}

console.log(`Updating hostname to ${schemeAndHost}`);
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(schemeAndHost);

cache.setCallSuccess();

if (oldValue !== schemeAndHost) {
console.log("SyncStorytellerApiConfig() - force session refresh")
// NB: This is a hack that might prevent the login screen from being shown
// if the user is working in development.
await forceGetUserInfoAndSubcriptions();
}
});
}
export const RefreshSessionAfterApiHostChange = async (): Promise<void> => {
await forceGetUserInfoAndSubcriptions();
};
Loading