feat: adding fullstory sdk package files

This commit is contained in:
Carlos 2025-02-20 22:05:04 -05:00
parent 4822e4502d
commit 47da5d782c
16 changed files with 2558 additions and 72 deletions

View File

@ -1,11 +0,0 @@
{
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
"env": {
"node": true,
"es2021": true
},
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "module"
}
}

281
README.md
View File

@ -1 +1,280 @@
# fullstory-asset-sdk
# FullStory Asset SDK
A lightweight SDK for integrating **FullStory session tracking and asset uploading** into React applications with CLI automation.
## Table of Contents
- [Installation Guide](#installation-guide)
- [Usage Documentation](#usage-documentation)
- [Configuration Options](#configuration-options)
- [Implementation Examples](#implementation-examples)
- [Troubleshooting](#troubleshooting)
- [Contributing Guidelines](#contributing-guidelines)
- [Technical Details](#technical-details)
---
## Installation Guide
### Prerequisites
- Node.js `>=14.0`
- npm or yarn installed
- A **FullStory API Key**
- A valid **FullStory Organization ID** (`orgId`)
### Installation
You can install the package using `npm` or `yarn`:
```sh
npm install fullstory-asset-sdk
# or
yarn add fullstory-asset-sdk
```
This will install:
- A **React Provider Component** (`FullStoryProvider`) for easy integration.
- A **CLI tool** (`fullstory-uploader`) for manual asset uploads.
- A **cross-platform asset uploader** to manage FullStory assets.
---
## Usage Documentation
### 1. Using the FullStory Provider in a React App
To dynamically track sessions and manage FullStory, wrap your application with the `FullStoryProvider`:
```jsx
import React from "react";
import { FullStoryProvider } from "fullstory-asset-sdk";
function App() {
return (
<FullStoryProvider
apiKey="fs-api-key-here"
assetMapPath="path/to/asset_map.json"
orgId="your-org-id"
>
<h1>My React App</h1>
</FullStoryProvider>
);
}
export default App;
```
### 2. CLI Tool for Asset Uploading
The package includes a command-line interface (CLI) for manually uploading assets.
```sh
npx fullstory-uploader -k YOUR_API_KEY -a path/to/asset-map.json
```
#### CLI Options
| Option | Description |
| ----------------------- | ------------------------------------------ |
| `-k, --apiKey <key>` | FullStory API Key (required) |
| `-a, --assetMap <path>` | Path to the Asset Map JSON file (required) |
This command **uploads assets** to FullStory, ensuring they are correctly mapped.
---
## Configuration Options
When using `FullStoryProvider`, you need to pass:
- `apiKey` (**Required**) - Your FullStory API Key
- `orgId` (**Required**) - Your FullStory Organization ID
- `assetMapPath` (**Required**) - Path to your asset map file
Example:
```jsx
<FullStoryProvider apiKey="your-api-key" orgId="your-org-id" />
```
---
## Implementation Examples
### 1. JavaScript React Project
This package **fully supports JavaScript projects** even though it is written in TypeScript. You can directly use it in a `JS`-based React app.
```js
import { FullStoryProvider } from "fullstory-asset-sdk";
function App() {
return (
<FullStoryProvider
apiKey="fs-api-key-here"
assetMapPath="path/to/asset_map.json"
orgId="your-org-id"
>
<h1>My React App</h1>
</FullStoryProvider>
);
}
export default App;
```
### 2. Using with Next.js
If using **Next.js**, import `FullStoryProvider` inside `_app.js`:
```js
import { FullStoryProvider } from "fullstory-asset-sdk";
function MyApp({ Component, pageProps }) {
return (
<FullStoryProvider
apiKey="fs-api-key-here"
assetMapPath="path/to/asset_map.json"
orgId="your-org-id"
>
<Component {...pageProps} />
</FullStoryProvider>
);
}
export default MyApp;
```
### 3. Integrating with Webpack/Vite
For Webpack or Vite projects, **ensure the asset uploader runs before build**.
Example Webpack config:
```js
module.exports = {
plugins: [
new webpack.DefinePlugin({
"process.env.FS_API_KEY": JSON.stringify(process.env.FS_API_KEY),
}),
],
};
```
---
## Troubleshooting
### Common Issues
#### 1. FullStory Not Loading
- Ensure the API key and `orgId` are **correct**.
- Check the console for errors using:
```sh
npx fullstory-uploader -k YOUR_API_KEY -a path/to/asset-map.json
```
#### 2. Asset Upload Fails
- Verify `assetMapPath` is correct.
- Check internet connectivity.
#### 3. CLI Command Not Found
If the CLI tool isnt recognized:
```sh
npx fullstory-uploader --help
```
or try reinstalling:
```sh
npm install -g fullstory-asset-sdk
```
---
## Contributing Guidelines
### Project Structure
```
fullstory-asset-sdk/
│── src/
│ ├── FullStoryProvider.tsx # React Provider Component
│ ├── fullstoryUploader.ts # Asset upload logic
│ ├── index.ts # Package entry point
│ ├── cli.ts # CLI command script
│ ├── generate-asset-map-id.js # Cross-platform asset uploader
│── tests/ # Jest test files
│── package.json # Package metadata & dependencies
│── README.md # Documentation
```
### Development Setup
To set up the project locally:
```sh
git clone https://github.com/CarGDev/fullstory-asset-sdk.git
cd fullstory-asset-sdk
npm install
```
To run tests:
```sh
npm test
```
### Submitting Issues
Before opening an issue:
- **Check existing issues** to avoid duplicates.
- Provide **steps to reproduce** the problem.
- Include **error messages and logs**.
Create an issue here: [GitHub Issues](https://github.com/CarGDev/fullstory-asset-sdk/issues)
---
## Technical Details
### 1. How the Package Works
- **Cross-Platform Asset Uploading:**
- The script (`generate-asset-map-id.js`) **detects OS** and downloads the correct FullStory asset uploader binary.
- **Dynamic Asset Map Handling:**
- The provider **fetches the asset map** dynamically during app initialization.
- **Optimized Performance:**
- The SDK only loads FullStory **when needed**.
- Avoids reloading FullStory **on re-renders**.
### 2. Security Considerations
- API keys should **never be hardcoded**.
- Use **environment variables** to store credentials.
```sh
export FS_API_KEY="your-api-key"
```
---
## Final Notes
This SDK makes **FullStory integration seamless** by automating:
- **Session tracking**
- **Asset management**
- **React provider initialization**
For any issues or contributions, check:
👉 **[GitHub Repository](https://github.com/CarGDev/fullstory-asset-sdk)**

View File

@ -0,0 +1,42 @@
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const fullStoryUrls = {
win32:
"https://downloads.fullstory.com/asset-uploader/fullstory-asset-uploader-windows.exe",
darwin:
"https://downloads.fullstory.com/asset-uploader/fullstory-asset-uploader-mac",
linux:
"https://downloads.fullstory.com/asset-uploader/fullstory-asset-uploader-linux",
};
const currentOS = os.platform();
const fullStoryUrl = fullStoryUrls[currentOS];
if (!fullStoryUrl) {
console.error(`❌ Unsupported OS: ${currentOS}`);
process.exit(1);
}
const binDir = path.join(__dirname, "../bin");
const outputFile = path.join(binDir, "fullstory-asset-uploader");
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
try {
console.log(`⬇️ Downloading FullStory Asset Uploader for ${currentOS}...`);
execSync(`curl -o ${outputFile} ${fullStoryUrl}`, { stdio: "inherit" });
if (currentOS !== "win32") {
fs.chmodSync(outputFile, "755");
}
console.log("✅ FullStory Asset Uploader installed successfully!");
} catch (error) {
console.error("❌ Failed to download FullStory Asset Uploader", error);
process.exit(1);
}

25
eslint.config.js Normal file
View File

@ -0,0 +1,25 @@
const js = require("@eslint/js");
const ts = require("@typescript-eslint/eslint-plugin");
const tsParser = require("@typescript-eslint/parser");
/** @type {import("eslint").Linter.FlatConfig[]} */
module.exports = [
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: process.cwd(),
},
},
plugins: {
"@typescript-eslint": ts,
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
];

14
jest.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true
}
]
},
extensionsToTreatAsEsm: [".ts"],
};

1725
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,14 +28,16 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"generate-asset-map": "node bin/generate-asset-map-id.js",
"start": "npm run generate-asset-map && react-scripts start",
"test": "jest --coverage",
"lint": "eslint 'src/**/*.ts'",
"prepare": "npm run build",
"prepublishOnly": "npm run build"
"prepare": "npm run build"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
@ -46,6 +48,18 @@
},
"dependencies": {
"@fullstory/browser": "^2.0.6",
"minimist": "^1.2.8"
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"axios": "^1.7.9",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"dotenv": "^16.4.7",
"fs-extra": "^11.3.0",
"minimist": "^1.2.8",
"react": "^18.0.0",
"ts-node": "^10.9.2"
},
"bin": {
"fullstory-uploader": "dist/cli.js"
}
}

79
src/FullStoryProvider.tsx Normal file
View File

@ -0,0 +1,79 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from "react";
import FullStoryUploader from "./fullstoryUploader";
// Define TypeScript interface for the context
interface FullStoryContextProps {
assetMapId: string | null;
}
// Create a React Context with default values
const FullStoryContext = createContext<FullStoryContextProps>({
assetMapId: null,
});
// Custom hook to access FullStory context
export const useFullStory = () => useContext(FullStoryContext);
// Define props for the FullStoryProvider
interface FullStoryProviderProps {
apiKey: string;
assetMapPath: string;
orgId: string; // Mandatory orgId
children: ReactNode; // ReactNode for proper children typing
}
export const FullStoryProvider: React.FC<FullStoryProviderProps> = ({
apiKey,
assetMapPath,
orgId,
children,
}) => {
const [assetMapId, setAssetMapId] = useState<string | null>(null);
useEffect(() => {
const uploader = new FullStoryUploader({ apiKey, assetMapPath });
uploader
.uploadAssets()
.then(() => {
console.log("✅ FullStory Assets Uploaded Successfully");
// Example: Generate a dynamic asset_map_id
const generatedId = Date.now().toString(36);
setAssetMapId(generatedId);
// ✅ Define FullStory global variables with type assertions
(window as any)._fs_host = "fullstory.com";
(window as any)._fs_script = "edge.fullstory.com/s/fs.js";
(window as any)._fs_namespace = "FS";
(window as any)._fs_org = orgId;
(window as any)._fs_asset_map_id = generatedId;
const script = document.createElement("script");
script.src = `https://${(window as any)._fs_script}`;
script.async = true;
script.crossOrigin = "anonymous";
script.onerror = () =>
console.error("❌ Error loading FullStory script");
document.body.appendChild(script);
return () => document.body.removeChild(script);
})
.catch((error) => {
console.error("❌ FullStory Asset Upload Failed:", error);
});
}, [apiKey, assetMapPath, orgId]);
return (
<FullStoryContext.Provider value={{ assetMapId }}>
{children}
</FullStoryContext.Provider>
);
};

29
src/cli.ts Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
import { program } from "commander";
import * as dotenv from "dotenv";
import FullStoryUploader from "./fullstoryUploader";
dotenv.config();
program
.version("0.0.1")
.description("FullStory Asset Uploader CLI")
.requiredOption("-k, --apiKey <key>", "FullStory API Key")
.requiredOption("-a, --assetMap <path>", "Path to Asset Map JSON file")
.action((options) => {
const uploader = new FullStoryUploader({
apiKey: options.apiKey,
assetMapPath: options.assetMap,
});
uploader.uploadAssets();
});
program.configureOutput({
writeErr: (str) => {
console.error(str.trim());
process.exit(1);
},
});
program.parse(process.argv);

49
src/fullstoryUploader.ts Normal file
View File

@ -0,0 +1,49 @@
import fs from "fs-extra";
import * as path from "path";
import axios from "axios";
interface UploadOptions {
apiKey: string;
assetMapPath: string;
}
class FullStoryUploader {
private apiKey: string;
private assetMapPath: string;
constructor(options: UploadOptions) {
this.apiKey = options.apiKey;
this.assetMapPath = options.assetMapPath;
}
public async uploadAssets() {
try {
if (!fs.existsSync(this.assetMapPath)) {
console.error(`❌ Asset map file not found: ${this.assetMapPath}`);
return;
}
const assetMap = fs.readFileSync(this.assetMapPath, "utf-8");
const response = await axios.post(
"https://api.fullstory.com/assets/upload",
JSON.parse(assetMap),
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
},
);
if (response.status === 200) {
console.log("✅ Assets uploaded successfully!");
} else {
console.error(`❌ Failed to upload assets. Status: ${response.status}`);
}
} catch (error) {
console.error("❌ Error uploading assets", error);
}
}
}
export default FullStoryUploader;

View File

@ -0,0 +1,6 @@
import FullStoryUploader from "./fullstoryUploader";
export function uploadAssets(apiKey: string, assetMapPath: string) {
const uploader = new FullStoryUploader({ apiKey, assetMapPath });
return uploader.uploadAssets();
}

View File

196
tests/cli.test.ts Normal file
View File

@ -0,0 +1,196 @@
import { jest } from "@jest/globals";
import * as fs from "fs";
import * as path from "path";
// Create mock functions
const mockUploadAssets = jest.fn();
const mockFullStoryUploader = jest.fn().mockImplementation(() => ({
uploadAssets: mockUploadAssets,
}));
// Mock modules before importing the CLI
jest.mock("../src/fullstoryUploader", () => ({
__esModule: true,
default: mockFullStoryUploader,
}));
// Mock dotenv
jest.mock("dotenv", () => ({
config: jest.fn(),
}));
describe("FullStory Asset Uploader CLI", () => {
const testAssetMapPath = path.resolve(__dirname, "test-asset-map.json");
const testApiKey = "test-api-key";
// Create a test asset map file before tests
beforeAll(() => {
const testAssetMap = {
assets: [
{ id: "asset1", path: "./asset1.js" },
{ id: "asset2", path: "./asset2.css" },
],
};
fs.writeFileSync(testAssetMapPath, JSON.stringify(testAssetMap, null, 2));
});
// Clean up after tests
afterAll(() => {
if (fs.existsSync(testAssetMapPath)) {
fs.unlinkSync(testAssetMapPath);
}
});
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
// Reset modules to ensure clean Commander instance for each test
jest.resetModules();
});
it("should exit with an error when missing required arguments", () => {
// Mock console.error and process.exit
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code}) was called`);
});
try {
// Run CLI with no arguments
require("../src/cli");
} catch (e) {
const error = e as Error;
expect(error.message).toContain("process.exit(1)");
}
// ✅ Ensure an error message was logged
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("error: required option"),
);
// ✅ Ensure process.exit was called
expect(exitSpy).toHaveBeenCalledWith(1);
// Restore original implementations
consoleSpy.mockRestore();
exitSpy.mockRestore();
});
it("should initialize FullStoryUploader and call uploadAssets with correct options", () => {
// Mock process.argv
const originalArgv = process.argv;
process.argv = [
"node",
"cli.js",
"--apiKey",
testApiKey,
"--assetMap",
testAssetMapPath,
];
try {
// Import CLI module which will execute the program
require("../src/cli");
// Verify FullStoryUploader was constructed with correct params
expect(mockFullStoryUploader).toHaveBeenCalledWith({
apiKey: testApiKey,
assetMapPath: testAssetMapPath,
});
// Verify uploadAssets was called
expect(mockUploadAssets).toHaveBeenCalled();
} finally {
// Restore original process.argv
process.argv = originalArgv;
}
});
it("should initialize FullStoryUploader with short flag options", () => {
// Mock process.argv
const originalArgv = process.argv;
process.argv = ["node", "cli.js", "-k", testApiKey, "-a", testAssetMapPath];
try {
// Import CLI module which will execute the program
require("../src/cli");
// Verify FullStoryUploader was constructed with correct params
expect(mockFullStoryUploader).toHaveBeenCalledWith({
apiKey: testApiKey,
assetMapPath: testAssetMapPath,
});
// Verify uploadAssets was called
expect(mockUploadAssets).toHaveBeenCalled();
} finally {
// Restore original process.argv
process.argv = originalArgv;
}
});
it("should display version information with --version flag", () => {
// ✅ Mock process.exit to prevent Jest from failing
const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit(0) was called");
});
// ✅ Capture stdout output
const originalWrite = process.stdout.write;
let output = "";
process.stdout.write = (chunk: any) => {
output += chunk;
return true;
};
// Mock process.argv
const originalArgv = process.argv;
process.argv = ["node", "cli.js", "--version"];
try {
require("../src/cli");
} catch (e) {
// Ignore expected process.exit error
const error = e as Error;
expect(error.message).toContain("process.exit");
}
// ✅ Ensure the version is in the output
expect(output).toContain("0.0.1");
// Restore mocks and original argv
process.stdout.write = originalWrite;
process.argv = originalArgv;
exitSpy.mockRestore();
});
it("should display help information with --help flag", () => {
// Mock console.log and process.exit
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
const exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code}) was called`);
});
// Mock process.argv
const originalArgv = process.argv;
process.argv = ["node", "cli.js", "--help"];
try {
// Import CLI module
require("../src/cli");
} catch (e) {
// Expected error from process.exit()
const error = e as Error;
expect(error.message).toContain("process.exit");
}
// Restore mocks and original argv
consoleSpy.mockRestore();
exitSpy.mockRestore();
process.argv = originalArgv;
});
});

View File

@ -0,0 +1,77 @@
import FullStoryUploader from "../src/fullstoryUploader";
import fs from "fs-extra";
import axios from "axios";
// Mock fs-extra to simulate file existence and content reading
jest.mock("fs-extra", () => ({
existsSync: jest.fn(() => true),
readFileSync: jest.fn(() => JSON.stringify({ assets: {}, version: "2" })),
}));
// Mock axios to simulate API responses
jest.mock("axios");
describe("FullStoryUploader", () => {
const mockApiKey = "test-api-key";
const mockAssetMapPath = "test/assets.json";
it("should upload assets successfully", async () => {
// Mock successful API response
(axios.post as jest.Mock).mockResolvedValue({ status: 200 });
const uploader = new FullStoryUploader({
apiKey: mockApiKey,
assetMapPath: mockAssetMapPath,
});
const consoleSpy = jest.spyOn(console, "log").mockImplementation();
await uploader.uploadAssets();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("✅ Assets uploaded successfully!"),
);
consoleSpy.mockRestore();
});
it("should handle file not found error", async () => {
// Simulate a missing file
(fs.existsSync as jest.Mock).mockReturnValue(false);
const uploader = new FullStoryUploader({
apiKey: mockApiKey,
assetMapPath: "nonexistent.json",
});
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
await uploader.uploadAssets();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("❌ Asset map file not found"),
);
consoleSpy.mockRestore();
});
it("should handle API errors", async () => {
// Ensure the file exists so the API call runs
(fs.existsSync as jest.Mock).mockReturnValue(true);
// Simulate an API error
const apiError = new Error("API error");
(axios.post as jest.Mock).mockRejectedValue(apiError);
const uploader = new FullStoryUploader({
apiKey: mockApiKey,
assetMapPath: mockAssetMapPath,
});
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
await uploader.uploadAssets();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("❌ Error uploading assets"),
apiError, // This ensures we match the error object in the log
);
consoleSpy.mockRestore();
});
});

70
tests/index.test.ts Normal file
View File

@ -0,0 +1,70 @@
import { jest } from '@jest/globals';
import * as fs from 'fs';
import * as path from 'path';
// Create mock functions with explicit types
const mockUploadAssets = jest.fn<() => Promise<{ success: boolean }>>().mockResolvedValue({ success: true });
const mockFullStoryUploader = jest.fn().mockImplementation(() => ({
uploadAssets: mockUploadAssets,
}));
// Mock modules before importing the index
jest.mock('../src/fullstoryUploader', () => ({
__esModule: true,
default: mockFullStoryUploader,
}));
// Import the module under test
import { uploadAssets } from '../src/index';
describe('Index Module', () => {
const testAssetMapPath = path.resolve(__dirname, 'test-asset-map.json');
const testApiKey = 'test-api-key';
beforeAll(() => {
const testAssetMap = {
assets: [
{ id: 'asset1', path: './asset1.js' },
{ id: 'asset2', path: './asset2.css' },
],
};
fs.writeFileSync(testAssetMapPath, JSON.stringify(testAssetMap, null, 2));
});
afterAll(() => {
if (fs.existsSync(testAssetMapPath)) {
fs.unlinkSync(testAssetMapPath);
}
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize FullStoryUploader with correct params and call uploadAssets', async () => {
const result = await uploadAssets(testApiKey, testAssetMapPath);
expect(mockFullStoryUploader).toHaveBeenCalledWith({
apiKey: testApiKey,
assetMapPath: testAssetMapPath,
});
expect(mockUploadAssets).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should propagate errors from FullStoryUploader.uploadAssets', async () => {
const testError = new Error('Upload failed');
mockUploadAssets.mockRejectedValueOnce(testError);
await expect(uploadAssets(testApiKey, testAssetMapPath)).rejects.toThrow(testError);
expect(mockFullStoryUploader).toHaveBeenCalledWith({
apiKey: testApiKey,
assetMapPath: testAssetMapPath,
});
});
});

View File

@ -5,7 +5,9 @@
"target": "ES2022",
"strict": true,
"declaration": true,
"sourceMap": true
"sourceMap": true,
"esModuleInterop": true,
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]