diff --git a/js/README.md b/js/README.md index c8a58565..8d1674ea 100644 --- a/js/README.md +++ b/js/README.md @@ -44,7 +44,7 @@ schematic.identify({ schematic.track({ event: "query" }); // Check a flag -schematic.checkFlag("some-flag-key"); +await schematic.checkFlag("some-flag-key"); ``` By default, `checkFlag` will perform a network request to get the flag value for this user. If you'd like to check all flags at once in order to minimize network requests, you can use `checkFlags`: @@ -61,7 +61,7 @@ schematic.identify({ }, }); -schematic.checkFlags(); +await schematic.checkFlags(); ``` Alternatively, you can run in websocket mode, which will keep a persistent connection open to the Schematic service and receive flag updates in real time: @@ -69,14 +69,14 @@ Alternatively, you can run in websocket mode, which will keep a persistent conne ```typescript import { Schematic } from "@schematichq/schematic-js"; -const schematic = new Schematic("your-api-key", { useWebSocket: true}); +const schematic = new Schematic("your-api-key", { useWebSocket: true }); schematic.identify({ keys: { id: "my-user-id" }, company: { keys: { id: "my-company-id" } }, }); -schematic.checkFlag("some-flag-key"); +await schematic.checkFlag("some-flag-key"); ``` ## License diff --git a/js/jest.config.js b/js/jest.config.js index d91690f9..7b2c2f1a 100644 --- a/js/jest.config.js +++ b/js/jest.config.js @@ -3,6 +3,8 @@ module.exports = { preset: "ts-jest/presets/js-with-ts-esm", testEnvironment: "jsdom", transform: { - '^.+\\.(ts|tsx)?$': 'ts-jest', + "^.+\\.(ts|tsx)?$": "ts-jest", }, }; + +global.WebSocket = require("mock-socket").WebSocket; diff --git a/js/package.json b/js/package.json index f4c9ae46..f23a7187 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@schematichq/schematic-js", - "version": "1.0.2", + "version": "1.0.3", "main": "dist/schematic.cjs.js", "module": "dist/schematic.esm.js", "types": "dist/schematic.d.ts", @@ -44,6 +44,7 @@ "jest-environment-jsdom": "^29.7.0", "jest-esbuild": "^0.3.0", "jest-fetch-mock": "^3.0.3", + "mock-socket": "^9.3.1", "prettier": "^3.3.3", "ts-jest": "^29.2.5", "typescript": "^5.6.2" diff --git a/js/src/index.spec.ts b/js/src/index.spec.ts index 15af349e..065256a9 100644 --- a/js/src/index.spec.ts +++ b/js/src/index.spec.ts @@ -1,4 +1,5 @@ import { Schematic } from "./index"; +import { Server as WebSocketServer } from "mock-socket"; const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -351,3 +352,254 @@ describe("Schematic", () => { }); }); }); + +describe("Schematic WebSocket", () => { + let schematic: Schematic; + let mockServer: WebSocketServer; + const TEST_WS_URL = "ws://localhost:1234"; + const FULL_WS_URL = `${TEST_WS_URL}/flags/bootstrap`; + + beforeEach(() => { + mockServer?.stop(); + mockServer = new WebSocketServer(FULL_WS_URL); + schematic = new Schematic("API_KEY", { + useWebSocket: true, + webSocketUrl: TEST_WS_URL, + }); + }); + + afterEach(async () => { + await schematic.cleanup(); + mockServer.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + mockFetch.mockClear(); + }); + + it("should wait for WebSocket connection and initial message before returning flag value", async () => { + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + mockServer.on("connection", (socket) => { + socket.on("message", (data) => { + const parsedData = JSON.parse(data.toString()); + expect(parsedData).toEqual({ + apiKey: "API_KEY", + data: context, + }); + + setTimeout(() => { + socket.send( + JSON.stringify({ + flags: [ + { + flag: "TEST_FLAG", + value: true, + }, + ], + }), + ); + }, 100); + }); + }); + + const flagValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + + expect(flagValue).toBe(true); + }); + + it("should handle connection closing and reopening", async () => { + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + let connectionCount = 0; + + mockServer.on("connection", (socket) => { + connectionCount++; + socket.on("message", () => { + socket.send( + JSON.stringify({ + flags: [ + { + flag: "TEST_FLAG", + value: true, + }, + ], + }), + ); + }); + }); + + const firstCheckResult = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + expect(firstCheckResult).toBe(true); + expect(connectionCount).toBe(1); + + mockServer.stop(); + await schematic.cleanup(); + await new Promise((resolve) => setTimeout(resolve, 500)); + + mockServer = new WebSocketServer(FULL_WS_URL); + mockServer.on("connection", (socket) => { + connectionCount++; + socket.on("message", () => { + socket.send( + JSON.stringify({ + flags: [ + { + flag: "TEST_FLAG", + value: true, + }, + ], + }), + ); + }); + }); + + const secondCheckResult = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + + expect(secondCheckResult).toBe(true); + expect(connectionCount).toBe(2); + }, 15000); + + it("should fall back to REST API if WebSocket connection fails", async () => { + mockServer.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + // successful API response + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + data: { + value: true, + flag: "TEST_FLAG", + companyId: context.company?.companyId, + userId: context.user?.userId, + }, + }), + }), + ); + + const flagValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + + expect(flagValue).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should return fallback value if REST API call fails", async () => { + mockServer.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + // API response with server error + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }), + ); + + const flagValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: true, + }); + + expect(flagValue).toBe(true); // fallback value + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should return fallback value if REST API call throws", async () => { + mockServer.stop(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + // network error + mockFetch.mockImplementationOnce(() => + Promise.reject(new Error("Network error")), + ); + + const flagValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: true, + }); + + expect(flagValue).toBe(true); // fallback value + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should use cached values for subsequent checks", async () => { + const context = { + company: { companyId: "456" }, + user: { userId: "123" }, + }; + + let messageCount = 0; + mockServer.on("connection", (socket) => { + socket.on("message", () => { + messageCount++; + socket.send( + JSON.stringify({ + flags: [ + { + flag: "TEST_FLAG", + value: true, + }, + ], + }), + ); + }); + }); + + const firstValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + + const secondValue = await schematic.checkFlag({ + key: "TEST_FLAG", + context, + fallback: false, + }); + + expect(firstValue).toBe(true); + expect(secondValue).toBe(true); + expect(messageCount).toBe(1); + }); +}); diff --git a/js/src/index.ts b/js/src/index.ts index 5717d33e..cef59ad3 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -74,45 +74,113 @@ export class Schematic { } } - // Get value for a single flag - // If in websocket mode, return the local value, otherwise make an API call + /** + * Get value for a single flag. + * In WebSocket mode, returns cached values if connection is active, otherwise establishes + * new connection and then returns the requestedvalue. Falls back to REST API if WebSocket + * connection fails. + * In REST mode, makes an API call for each check. + */ async checkFlag(options: CheckOptions): Promise { const { fallback = false, key } = options; const context = options.context || this.context; + const contextStr = contextString(context); + + if (!this.useWebSocket) { + const requestUrl = `${this.apiUrl}/flags/${key}/check`; + return fetch(requestUrl, { + method: "POST", + headers: { + ...(this.additionalHeaders ?? {}), + "Content-Type": "application/json;charset=UTF-8", + "X-Schematic-Api-Key": this.apiKey, + }, + body: JSON.stringify(context), + }) + .then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }) + .then((data) => { + return data.data.value; + }) + .catch((error) => { + console.error("There was a problem with the fetch operation:", error); + return fallback; + }); + } + + try { + // If we have an active connection, return a cached value if available + const existingVals = this.values[contextStr]; + if ( + this.conn && + typeof existingVals !== "undefined" && + typeof existingVals[key] !== "undefined" + ) { + return existingVals[key]; + } + + // If we don't have values or connection is closed, we need to fetch them + try { + await this.setContext(context); + } catch (error) { + console.error( + "WebSocket connection failed, falling back to REST:", + error, + ); + return this.fallbackToRest(key, context, fallback); + } - if (this.useWebSocket) { - const contextVals = this.values[contextString(context)] ?? {}; + // After setting context and getting a response, return the value + const contextVals = this.values[contextStr] ?? {}; return typeof contextVals[key] === "undefined" ? fallback : contextVals[key]; + } catch (error) { + console.error("Unexpected error in checkFlag:", error); + return fallback; } + } - const requestUrl = `${this.apiUrl}/flags/${key}/check`; - return fetch(requestUrl, { - method: "POST", - headers: { - ...(this.additionalHeaders ?? {}), - "Content-Type": "application/json;charset=UTF-8", - "X-Schematic-Api-Key": this.apiKey, - }, - body: JSON.stringify(context), - }) - .then((response) => { - if (!response.ok) { - throw new Error("Network response was not ok"); - } - return response.json(); - }) - .then((data) => { - return data.data.value; - }) - .catch((error) => { - console.error("There was a problem with the fetch operation:", error); - return fallback; + /** + * Helper method for falling back to REST API when WebSocket connection fails + */ + private async fallbackToRest( + key: string, + context: SchematicContext, + fallback: boolean, + ): Promise { + try { + const requestUrl = `${this.apiUrl}/flags/${key}/check`; + const response = await fetch(requestUrl, { + method: "POST", + headers: { + ...(this.additionalHeaders ?? {}), + "Content-Type": "application/json;charset=UTF-8", + "X-Schematic-Api-Key": this.apiKey, + }, + body: JSON.stringify(context), }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + return data.data.value; + } catch (error) { + console.error("REST API call failed, using fallback value:", error); + return fallback; + } } - // Make an API call to fetch all flag values for a given context (use if not in websocket mode) + /** + * Make an API call to fetch all flag values for a given context. + * Recommended for use in REST mode only. + */ checkFlags = async ( context?: SchematicContext, ): Promise> => { @@ -153,7 +221,11 @@ export class Schematic { }); }; - // Send an identify event + /** + * Send an identify event. + * This will set the context for subsequent flag evaluation and events, and will also + * send an identify event to the Schematic API which will upsert a user and company. + */ identify = (body: EventBodyIdentify): Promise => { this.setContext({ company: body.company?.keys, @@ -163,10 +235,14 @@ export class Schematic { return this.handleEvent("identify", body); }; - // Set the flag evaluation context; if the context has changed, - // this will open a websocket connection (if not already open) - // and submit this context. The promise will resolve when the - // websocket sends back an initial set of flag values. + /** + * Set the flag evaluation context. + * In WebSocket mode, this will: + * 1. Open a websocket connection if not already open + * 2. Send the context to the server + * 3. Wait for initial flag values to be returned + * The promise resolves when initial flag values are received. + */ setContext = async (context: SchematicContext): Promise => { if (!this.useWebSocket) { this.context = context; @@ -183,11 +259,15 @@ export class Schematic { const socket = await this.conn; await this.wsSendMessage(socket, context); } catch (error) { - console.error("Error setting Schematic context:", error); + console.error("Failed to establish WebSocket connection:", error); + throw error; } }; - // Send track event + /** + * Send a track event + * Track usage for a company and/or user. + */ track = (body: EventBodyTrack): Promise => { const { company, user, event, traits } = body; return this.handleEvent("track", { @@ -275,6 +355,9 @@ export class Schematic { * Websocket management */ + /** + * If using websocket mode, close the connection when done. + */ cleanup = async (): Promise => { if (this.conn) { try {