Initial commit: Set up monorepo with SSE client package

This commit is contained in:
hitchhiker 2025-04-16 22:05:03 +02:00
commit b4d35ac4ab
32 changed files with 7748 additions and 0 deletions

72
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,72 @@
name: Publish Package
on:
workflow_dispatch:
inputs:
package:
description: 'Package directory to publish (e.g., packages/sse-client)'
required: true
version_bump:
description: 'Version bump (patch, minor, major, none, or version:x.y.z)'
required: true
default: 'patch'
type: 'string'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Set up PNPM
uses: pnpm/action-setup@v2
with:
version: 8
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.GITEA_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan git.generation.one >> ~/.ssh/known_hosts
- name: Set up Git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Install dependencies
run: pnpm install
- name: Publish package
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
echo "@g1:registry=https://git.generation.one/api/packages/GenerationOne/npm/" > .npmrc
echo "//git.generation.one/api/packages/GenerationOne/npm/:_authToken=${GITEA_TOKEN}" >> .npmrc
echo "legacy-peer-deps=true" >> .npmrc
echo "resolution-mode=highest" >> .npmrc
cd ${{ github.event.inputs.package }}
if [ "${{ github.event.inputs.version_bump }}" != "none" ]; then
# Check if it's a specific version (format: version:x.y.z)
if [[ "${{ github.event.inputs.version_bump }}" == version:* ]]; then
SPECIFIC_VERSION="${{ github.event.inputs.version_bump }}"
SPECIFIC_VERSION="${SPECIFIC_VERSION#version:}"
echo "Setting specific version ($SPECIFIC_VERSION)..."
npm version "$SPECIFIC_VERSION" --no-git-tag-version --allow-same-version
else
echo "Bumping version (${{ github.event.inputs.version_bump }})..."
npm version ${{ github.event.inputs.version_bump }} --no-git-tag-version
fi
fi
pnpm build
pnpm publish --no-git-checks

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
lib/
coverage/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Deploy keys
.deploy-key
.deploy-key.pub
.gitea-token
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS files
.DS_Store
Thumbs.db

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
legacy-peer-deps=true
resolution-mode=highest

127
docs/DEPLOY_KEYS.md Normal file
View File

@ -0,0 +1,127 @@
# Deploy Keys for Publishing Packages
This document explains how to set up and use deploy keys for publishing packages to the Generation One Gitea repository.
## What are Deploy Keys?
Deploy keys are SSH keys that grant access to a specific repository. They are more secure than personal access tokens because:
1. They can be limited to a single repository
2. They can be read-only or read-write
3. They don't grant access to your personal account
## Setting Up Deploy Keys
### 1. Generate a Deploy Key
Run the provided script to generate a new deploy key:
```bash
# Make the script executable
chmod +x scripts/setup-deploy-key.sh
# Run the script
./scripts/setup-deploy-key.sh
```
This will:
- Generate a new SSH key pair (`.deploy-key` and `.deploy-key.pub`)
- Add the public key to the Gitea repository as a deploy key
### 2. Create a Gitea Token
1. Log in to https://git.generation.one
2. Go to your user settings (click your profile picture)
3. Select "Applications" or "Access Tokens"
4. Create a new token with the "packages:write" scope
5. Save the token to a file named `.gitea-token` in the root of the repository
## Creating New Packages
### Setting the Initial Version
When creating a new package, you can set the initial version in the `package.json` file:
```json
{
"name": "@g1/your-package-name",
"version": "0.2.0",
"description": "Your package description",
...
}
```
Choosing a version like `0.2.0` for a new package is a good practice as it indicates:
- The package is still in development (0.x.x)
- It has gone through some initial development (0.2.x rather than 0.1.x)
- It's starting with a clean minor version (x.x.0)
## Publishing Packages
### Manual Publishing
Use the provided script to publish a package:
```bash
# Make the script executable
chmod +x scripts/publish-package.sh
# Publish a package with a patch version bump
./scripts/publish-package.sh packages/sse-client patch
# Publish a package with a specific version
./scripts/publish-package.sh packages/sse-client version:0.2.0
# Publish a package without changing the version
./scripts/publish-package.sh packages/sse-client none
```
### Automated Publishing with GitHub Actions
A GitHub Actions workflow is included in this repository for automated publishing:
1. Go to the "Actions" tab in your GitHub repository
2. Select the "Publish Package" workflow
3. Click "Run workflow"
4. Enter the package directory (e.g., `packages/sse-client`)
5. Select the version bump type (patch, minor, major, or none)
6. Click "Run workflow"
### Setting Up GitHub Actions Secrets
For the GitHub Actions workflow to work, you need to add the following secrets to your repository:
1. `GITEA_DEPLOY_KEY`: The contents of the `.deploy-key` file
2. `GITEA_TOKEN`: The contents of the `.gitea-token` file
To add these secrets:
1. Go to your repository on GitHub
2. Click on "Settings"
3. Click on "Secrets and variables" > "Actions"
4. Click on "New repository secret"
5. Add each secret with the appropriate name and value
## Security Considerations
- **Never commit the private key or token to the repository**
- The `.deploy-key`, `.deploy-key.pub`, and `.gitea-token` files are already in `.gitignore`
- Rotate the deploy key and token periodically for better security
- Limit the permissions of the token to only what is necessary (packages:write)
## Troubleshooting
### Authentication Errors
If you encounter authentication errors when publishing:
1. Verify that your Gitea token has the correct permissions
2. Check that the token is correctly set in the `.gitea-token` file
3. Ensure the deploy key has been added to the repository with write access
### SSH Key Issues
If there are issues with the SSH key:
1. Verify that the key has been added to the repository
2. Check the permissions on the `.deploy-key` file (should be `600`)
3. Try regenerating the key with the setup script

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "g1-ts-common-packages",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"clean": "pnpm -r exec -- rm -rf dist node_modules"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"resolutions": {
"glob": "^7.2.3",
"inflight": "^1.0.6"
}
}

View File

@ -0,0 +1,4 @@
@g1:registry=https://git.generation.one/api/packages/GenerationOne/npm/
//git.generation.one/api/packages/GenerationOne/npm/:_authToken=c44ffb795a86532ca684f7a6642219fe6a15cf85
legacy-peer-deps=true
resolution-mode=highest

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024
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.

View File

@ -0,0 +1,156 @@
# Publishing the SSE Client Package
This document explains how to build and publish the SSE client package to the Generation One Gitea repository.
## Prerequisites
- Node.js and pnpm installed
- Access to the Generation One Gitea repository
- Gitea access token with `packages:write` permission
## Building the Package
1. Install dependencies:
```bash
cd sse-client
pnpm install
```
2. Build the package:
```bash
pnpm build
```
This will compile the TypeScript code and generate the distribution files in the `dist` directory.
## Configuration for Publishing
The package is already configured for publishing to the Generation One Gitea repository with the following files:
### package.json
```json
{
"name": "@g1/sse-client",
"version": "1.0.0",
"description": "A custom SSE client that supports headers and bypasses certificate issues",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "https://git.generation.one/GenerationOne/g1-ts-common-packages.git"
},
"publishConfig": {
"registry": "https://git.generation.one/api/packages/GenerationOne/npm/"
}
}
```
### .npmrc
```
# Gitea registry configuration
@g1:registry=https://git.generation.one/api/packages/GenerationOne/npm/
//git.generation.one/api/packages/GenerationOne/npm/:_authToken=${GITEA_TOKEN}
```
## Publishing to Gitea
### Method 1: Direct Publishing
1. Generate an access token in Gitea:
- Log in to https://git.generation.one
- Go to your user settings (click your profile picture)
- Select "Applications" or "Access Tokens"
- Create a new token with the "packages:write" scope
- Copy the generated token
2. Set the token as an environment variable:
```powershell
$env:GITEA_TOKEN = "your-token-here"
```
3. Publish the package:
```powershell
pnpm publish --no-git-checks
```
The `--no-git-checks` flag allows publishing without committing to Git first.
### Method 2: Commit to Git Repository First
If you prefer to commit the code to the Git repository before publishing:
1. Initialize Git and add the remote:
```powershell
git init
git add .
git commit -m "Initial commit of SSE client package"
git remote add origin https://git.generation.one/GenerationOne/g1-ts-common-packages.git
```
2. Create a branch for the package:
```powershell
git checkout -b feature/sse-client
```
3. Push to Gitea:
```powershell
git push -u origin feature/sse-client
```
4. Then publish the package:
```powershell
$env:GITEA_TOKEN = "your-token-here"
pnpm publish
```
## Using the Published Package
After publishing, you can use the package in other projects:
1. Add it to your dependencies:
```json
{
"dependencies": {
"@g1/sse-client": "1.0.0"
}
}
```
2. Configure your project's .npmrc file:
```
@g1:registry=https://git.generation.one/api/packages/GenerationOne/npm/
//git.generation.one/api/packages/GenerationOne/npm/:_authToken=${GITEA_TOKEN}
```
3. Install the package:
```bash
pnpm install
```
## Updating the Package
To update the package:
1. Make your changes
2. Update the version in package.json (following semantic versioning)
3. Build the package: `pnpm build`
4. Publish the new version: `pnpm publish --no-git-checks`
## Troubleshooting
- **Authentication Error**: Make sure your GITEA_TOKEN environment variable is set correctly
- **Version Conflict**: If the version already exists, update the version number in package.json
- **Registry Not Found**: Verify the registry URL in .npmrc and package.json

View File

@ -0,0 +1,176 @@
# SSE Client
A custom SSE (Server-Sent Events) client that supports headers and bypasses certificate issues. This package provides a robust implementation for connecting to SSE endpoints with features like:
- Custom headers support
- SSL certificate validation bypass
- Automatic reconnection with exponential backoff
- Connection management
- Event handling
- TypeScript support
## Installation
```bash
npm install sse-client
# or
yarn add sse-client
# or
pnpm add sse-client
```
## Basic Usage
```typescript
import { SSEClient } from 'sse-client';
// Create a new SSE client
const client = new SSEClient('https://api.example.com/events', {
headers: {
'Authorization': 'Bearer token123',
'X-Custom-Header': 'value'
},
debug: true // Enable debug logging
});
// Listen for events
client.on('message', (event) => {
console.log('Received message:', event.parsedData);
});
// Connect to the SSE endpoint
client.connect();
// Later, when you're done
client.close();
```
## SSL Certificate Bypass
In Node.js environments, you can bypass SSL certificate validation:
```typescript
import { SSEClient } from 'sse-client';
// Create a new SSE client with SSL certificate validation bypass
const client = new SSEClient('https://api.example.com/events', {
headers: {
'Authorization': 'Bearer token123'
},
skipSSLValidation: true, // Bypass SSL certificate validation
debug: true
});
client.on('message', (event) => {
console.log('Received message:', event.parsedData);
});
client.connect();
```
This is particularly useful for development environments or when dealing with self-signed certificates.
## Connection Management
The package includes a connection manager for handling multiple SSE connections:
```typescript
import { getSSEConnection, closeSSEConnection, closeAllSSEConnections } from 'sse-client';
// Get or create a connection
const client = getSSEConnection(
'https://api.example.com/events/orders/123',
'order-123', // Unique ID for this connection
{
headers: {
'Authorization': 'Bearer token123'
}
}
);
// Listen for events
client.on('orderStateChanged', (event) => {
console.log('Order state changed:', event.parsedData);
});
// Close a specific connection
closeSSEConnection('order-123');
// Close all connections
closeAllSSEConnections();
```
## Automatic Cleanup
You can set up automatic cleanup of all SSE connections when the page is unloaded:
```typescript
import { setupSSECleanup } from 'sse-client';
// Call this once in your application
setupSSECleanup();
```
## Configuration Options
The `SSEClient` constructor accepts the following options:
```typescript
interface SSEClientOptions {
// Headers to include in the SSE request
headers?: Record<string, string>;
// Whether to include credentials in the request (default: true)
withCredentials?: boolean;
// Timeout for heartbeat in milliseconds (default: 21600000 - 6 hours)
heartbeatTimeout?: number;
// Maximum number of retry attempts (default: 10)
maxRetryAttempts?: number;
// Initial delay for retry in milliseconds (default: 1000)
initialRetryDelay?: number;
// Maximum delay for retry in milliseconds (default: 30000)
maxRetryDelay?: number;
// Whether to automatically reconnect on error (default: true)
autoReconnect?: boolean;
// Debug mode (default: false)
debug?: boolean;
// Whether to bypass SSL certificate validation (default: false)
// Only works in Node.js environments
skipSSLValidation?: boolean;
}
```
## Event Handling
The SSE client provides methods for handling events:
```typescript
// Add an event listener
client.on('eventName', (event) => {
console.log('Event received:', event);
});
// Remove a specific event listener
client.off('eventName', listener);
// Remove all listeners for an event
client.removeAllListeners('eventName');
// Remove all listeners for all events
client.removeAllListeners();
```
## Building and Publishing
For instructions on how to build and publish this package to the Generation One Gitea repository, see [PUBLISHING.md](./PUBLISHING.md).
## License
MIT

View File

@ -0,0 +1,108 @@
# SSE Client Package Summary
## Overview
This package provides a robust implementation of a Server-Sent Events (SSE) client with the following features:
- Custom headers support for authentication
- SSL certificate validation bypass for development environments
- Automatic reconnection with exponential backoff
- Connection management for multiple SSE connections
- Event handling with JSON parsing
- TypeScript support
## Package Structure
```
sse-client/
├── dist/ # Compiled output
├── src/
│ ├── index.ts # Main entry point
│ ├── sseClient.ts # Core SSE client implementation
│ ├── utils/
│ │ └── debug.ts # Debug utilities
│ └── __tests__/
│ └── sseClient.test.ts # Unit tests
├── examples/
│ ├── order-tracking.ts # Basic usage example
│ ├── react-order-tracking.tsx # React example
│ ├── vue-order-tracking.vue # Vue example
│ ├── angular-order-tracking.component.ts # Angular example
│ ├── nodejs-sse-client.js # Node.js example
│ ├── browser-script.html # Browser script example
│ ├── nextjs-sse-proxy.ts # Next.js SSE proxy example
│ ├── express-sse-proxy.js # Express SSE proxy example
│ └── fastify-sse-proxy.js # Fastify SSE proxy example
├── package.json # Package configuration
├── tsconfig.json # TypeScript configuration
├── jest.config.js # Jest configuration
├── .npmrc # npm registry configuration
├── .gitignore # Git ignore file
├── LICENSE # MIT license
├── README.md # Documentation
└── PUBLISHING.md # Publishing instructions
```
## Key Components
### SSEClient
The main class that handles the connection to the SSE endpoint. It provides methods for:
- Connecting to the SSE endpoint
- Adding event listeners
- Handling reconnection
- Parsing event data
### SSEConnectionManager
A singleton class that manages multiple SSE connections. It provides methods for:
- Creating and retrieving connections
- Closing connections
- Cleaning up connections
### Utility Functions
- `getSSEConnection`: Gets or creates a connection for a given ID
- `closeSSEConnection`: Closes a connection for a given ID
- `closeAllSSEConnections`: Closes all connections
- `setupSSECleanup`: Sets up automatic cleanup of connections when the page is unloaded
## Configuration Options
The `SSEClient` constructor accepts the following options:
- `headers`: Headers to include in the SSE request
- `withCredentials`: Whether to include credentials in the request
- `heartbeatTimeout`: Timeout for heartbeat in milliseconds
- `maxRetryAttempts`: Maximum number of retry attempts
- `initialRetryDelay`: Initial delay for retry in milliseconds
- `maxRetryDelay`: Maximum delay for retry in milliseconds
- `autoReconnect`: Whether to automatically reconnect on error
- `debug`: Debug mode
- `skipSSLValidation`: Whether to bypass SSL certificate validation
## SSL Certificate Validation Bypass
The package includes the ability to bypass SSL certificate validation in Node.js environments, which is useful for development environments or when dealing with self-signed certificates.
## Publishing
The package is configured for publishing to the Generation One Gitea repository. See [PUBLISHING.md](./PUBLISHING.md) for detailed instructions.
## Usage Examples
The package includes examples for various frameworks and environments:
- Basic usage
- React integration
- Vue integration
- Angular integration
- Node.js usage
- Browser script
- Next.js SSE proxy
- Express SSE proxy
- Fastify SSE proxy
These examples demonstrate how to use the package in different contexts and provide a starting point for integration into your own projects.

View File

@ -0,0 +1,132 @@
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { getSSEConnection, setupSSECleanup, SSEClient } from 'sse-client';
// Set up SSE cleanup once at the application level
setupSSECleanup();
// Define the order state type
interface OrderState {
orderId: string;
orderStatus: number;
preparationState: {
utcStarted: string;
utcEnded: string | null;
};
deliveryState: {
utcStarted: string | null;
utcEnded: string | null;
};
// Add other properties as needed
}
// Define the order state changed message type
interface OrderStateChangedMessage {
messageType: string;
orderId: string;
prevState: OrderState;
currentState: OrderState;
}
@Component({
selector: 'app-order-tracker',
template: `
<div class="order-tracker">
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!orderState" class="loading">Loading order status...</div>
<div *ngIf="orderState">
<h2>Order #{{ orderState.orderId }}</h2>
<div class="order-status">
Status: {{ getStatusText(orderState.orderStatus) }}
</div>
<!-- Render other order details -->
</div>
</div>
`,
styles: [`
.order-tracker {
padding: 1rem;
border: 1px solid #eee;
border-radius: 4px;
}
.error {
color: red;
}
.loading {
color: #888;
}
`]
})
export class OrderTrackerComponent implements OnInit, OnDestroy {
@Input() orderId!: string;
@Input() authToken!: string;
orderState: OrderState | null = null;
error: string | null = null;
private client: SSEClient | null = null;
ngOnInit(): void {
// API base URL
const apiBaseUrl = 'https://api.example.com';
// SSE URL
const sseUrl = `${apiBaseUrl}/events/orders/${this.orderId}`;
// Get or create a connection for this order
this.client = getSSEConnection(sseUrl, `order-${this.orderId}`, {
headers: {
'X-Signed-Auth': this.authToken
},
debug: true
});
// Listen for order state changes
this.client.on('orderStateChanged', (event: any) => {
try {
const data = event.parsedData as OrderStateChangedMessage;
console.log('Order state changed:', data);
// Update state with new order state
this.orderState = data.currentState;
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ${this.orderId} ended, closing SSE connection`);
this.client?.close();
}
} catch (err) {
this.error = 'Error parsing order state data';
console.error('Error parsing order state data:', err);
}
});
// Listen for connection errors
this.client.on('error', (event: any) => {
this.error = 'SSE connection error';
console.error('SSE connection error:', event.detail);
});
}
ngOnDestroy(): void {
// Clean up on destroy
if (this.client) {
this.client.close();
this.client = null;
}
}
// Helper function to get status text from status code
getStatusText(status: number): string {
switch (status) {
case 0: return 'Created';
case 10: return 'Accepted';
case 20: return 'Preparing';
case 30: return 'Ready for Pickup';
case 40: return 'Out for Delivery';
case 50: return 'Delivered';
case 1111: return 'Completed';
default: return `Unknown (${status})`;
}
}
}

View File

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Client Example</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.order-tracker {
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
margin-top: 20px;
}
.status {
font-weight: bold;
}
.error {
color: red;
}
.events {
margin-top: 20px;
border: 1px solid #eee;
padding: 10px;
height: 300px;
overflow-y: auto;
}
.event {
margin-bottom: 10px;
padding: 5px;
border-bottom: 1px solid #eee;
}
.event-time {
color: #888;
font-size: 0.8em;
}
</style>
</head>
<body>
<h1>SSE Client Example</h1>
<div class="order-tracker">
<h2>Order Tracker</h2>
<div id="status" class="status">Connecting...</div>
<div id="error" class="error" style="display: none;"></div>
<h3>Events</h3>
<div id="events" class="events"></div>
<button id="connect">Connect</button>
<button id="disconnect">Disconnect</button>
</div>
<!-- Include the SSE client library -->
<script src="https://unpkg.com/sse-client@1.0.0/dist/index.js"></script>
<script>
// Get elements
const statusEl = document.getElementById('status');
const errorEl = document.getElementById('error');
const eventsEl = document.getElementById('events');
const connectBtn = document.getElementById('connect');
const disconnectBtn = document.getElementById('disconnect');
// Set up SSE cleanup
SSEClient.setupSSECleanup();
let client = null;
// Add event to the events list
function addEvent(type, data) {
const eventEl = document.createElement('div');
eventEl.className = 'event';
const timeEl = document.createElement('div');
timeEl.className = 'event-time';
timeEl.textContent = new Date().toLocaleTimeString();
const contentEl = document.createElement('div');
contentEl.innerHTML = `<strong>${type}:</strong> ${JSON.stringify(data)}`;
eventEl.appendChild(timeEl);
eventEl.appendChild(contentEl);
eventsEl.appendChild(eventEl);
eventsEl.scrollTop = eventsEl.scrollHeight;
}
// Connect to SSE
function connect() {
// Get auth token from localStorage
const authToken = localStorage.getItem('authToken') || '';
// Order ID (in a real app, this would come from your app state)
const orderId = '123456';
// API base URL
const apiBaseUrl = 'https://api.example.com';
// SSE URL
const sseUrl = `${apiBaseUrl}/events/orders/${orderId}`;
// Update status
statusEl.textContent = 'Connecting...';
errorEl.style.display = 'none';
// Create a new SSE client
client = new SSEClient.SSEClient(sseUrl, {
headers: {
'X-Signed-Auth': authToken
},
debug: true
});
// Listen for order state changes
client.on('orderStateChanged', (event) => {
try {
const data = event.parsedData;
console.log('Order state changed:', data);
// Add event to the events list
addEvent('Order State Changed', data);
// Update status
statusEl.textContent = `Status: ${getStatusText(data.currentState.orderStatus)}`;
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ${orderId} ended, closing SSE connection`);
disconnect();
}
} catch (err) {
console.error('Error parsing order state data:', err);
errorEl.textContent = 'Error parsing order state data';
errorEl.style.display = 'block';
}
});
// Listen for connection open
client.on('open', () => {
console.log('SSE connection opened');
addEvent('Connection', 'Opened');
statusEl.textContent = 'Connected';
});
// Listen for connection errors
client.on('error', (event) => {
console.error('SSE connection error:', event.detail);
addEvent('Error', event.detail);
errorEl.textContent = 'SSE connection error';
errorEl.style.display = 'block';
});
// Connect to the SSE endpoint
client.connect();
}
// Disconnect from SSE
function disconnect() {
if (client) {
client.close();
client = null;
// Update status
statusEl.textContent = 'Disconnected';
addEvent('Connection', 'Closed');
}
}
// Helper function to get status text from status code
function getStatusText(status) {
switch (status) {
case 0: return 'Created';
case 10: return 'Accepted';
case 20: return 'Preparing';
case 30: return 'Ready for Pickup';
case 40: return 'Out for Delivery';
case 50: return 'Delivered';
case 1111: return 'Completed';
default: return `Unknown (${status})`;
}
}
// Add event listeners to buttons
connectBtn.addEventListener('click', connect);
disconnectBtn.addEventListener('click', disconnect);
// Connect on page load
connect();
</script>
</body>
</html>

View File

@ -0,0 +1,78 @@
const express = require('express');
const axios = require('axios');
const app = express();
const port = 3000;
// Middleware to parse JSON
app.use(express.json());
// SSE proxy endpoint
app.get('/api/proxy/sse/events/orders/:orderId', async (req, res) => {
try {
// Get the order ID from the request parameters
const orderId = req.params.orderId;
// Get the auth header from the request
const authHeader = req.headers['x-signed-auth'];
// API base URL
const apiBaseUrl = process.env.API_ENDPOINT || 'https://api.example.com';
// Target URL
const targetUrl = `${apiBaseUrl}/events/orders/${orderId}`;
console.log(`Proxying SSE request to: ${targetUrl}`);
// Create headers for the proxy request
const headers = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
};
// Add auth header if available
if (authHeader) {
headers['X-Signed-Auth'] = authHeader;
}
// Make the request to the target URL
const response = await axios({
method: 'GET',
url: targetUrl,
headers,
responseType: 'stream',
// Skip SSL certificate validation for development
...(process.env.NODE_ENV === 'development' && {
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false })
})
});
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Copy other headers from the response
for (const [key, value] of Object.entries(response.headers)) {
if (key.toLowerCase() !== 'content-length') {
res.setHeader(key, value);
}
}
// Pipe the response stream to the client
response.data.pipe(res);
// Handle client disconnect
req.on('close', () => {
console.log(`Client disconnected from SSE for order ${orderId}`);
response.data.destroy();
});
} catch (error) {
console.error('Error in SSE proxy:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Start the server
app.listen(port, () => {
console.log(`SSE proxy server listening at http://localhost:${port}`);
});

View File

@ -0,0 +1,81 @@
const fastify = require('fastify')({ logger: true });
const axios = require('axios');
// SSE proxy endpoint
fastify.get('/api/proxy/sse/events/orders/:orderId', async (request, reply) => {
try {
// Get the order ID from the request parameters
const orderId = request.params.orderId;
// Get the auth header from the request
const authHeader = request.headers['x-signed-auth'];
// API base URL
const apiBaseUrl = process.env.API_ENDPOINT || 'https://api.example.com';
// Target URL
const targetUrl = `${apiBaseUrl}/events/orders/${orderId}`;
fastify.log.info(`Proxying SSE request to: ${targetUrl}`);
// Create headers for the proxy request
const headers = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
};
// Add auth header if available
if (authHeader) {
headers['X-Signed-Auth'] = authHeader;
}
// Make the request to the target URL
const response = await axios({
method: 'GET',
url: targetUrl,
headers,
responseType: 'stream',
// Skip SSL certificate validation for development
...(process.env.NODE_ENV === 'development' && {
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false })
})
});
// Set headers for SSE
reply.header('Content-Type', 'text/event-stream');
reply.header('Cache-Control', 'no-cache');
reply.header('Connection', 'keep-alive');
// Copy other headers from the response
for (const [key, value] of Object.entries(response.headers)) {
if (key.toLowerCase() !== 'content-length') {
reply.header(key, value);
}
}
// Send the response
reply.send(response.data);
// Handle client disconnect
request.raw.on('close', () => {
fastify.log.info(`Client disconnected from SSE for order ${orderId}`);
response.data.destroy();
});
} catch (error) {
fastify.log.error('Error in SSE proxy:', error);
reply.status(500).send({ error: 'Internal server error' });
}
});
// Start the server
const start = async () => {
try {
await fastify.listen({ port: 3000 });
fastify.log.info(`SSE proxy server listening at http://localhost:3000`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@ -0,0 +1,93 @@
// Example Next.js API route for proxying SSE connections
// File: app/api/proxy/sse/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import https from 'https';
/**
* Proxy endpoint for SSE connections
* This endpoint forwards requests to the SSE endpoint and streams the response back to the client
*/
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
// Get the path from the request
const path = params.path.join('/');
// Get the API base URL from environment variables
const apiBaseUrl = process.env.API_ENDPOINT || 'https://api.example.com';
// Construct the target URL
const targetUrl = `${apiBaseUrl}/events/orders/${path}`;
// Get the auth header from the request
const authHeader = request.headers.get('X-Signed-Auth');
// Create headers for the proxy request
const headers: HeadersInit = {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
};
// Add auth header if available
if (authHeader) {
headers['X-Signed-Auth'] = authHeader;
}
// Make the request to the target URL
const response = await fetch(targetUrl, {
method: 'GET',
headers,
// Don't verify SSL in development or when explicitly configured
...((process.env.NODE_ENV === 'development' || process.env.SKIP_SSL_VALIDATION === 'true') && {
//@ts-ignore
agent: new https.Agent({ rejectUnauthorized: false }),
}),
});
// If the response is not OK, return an error
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to connect to SSE endpoint' },
{ status: response.status }
);
}
// Get the response body as a readable stream
const body = response.body;
// If the body is null, return an error
if (!body) {
return NextResponse.json(
{ error: 'Response body is null' },
{ status: 500 }
);
}
// Create a new response with the body stream
const newResponse = new NextResponse(body);
// Copy headers from the original response
response.headers.forEach((value, key) => {
newResponse.headers.set(key, value);
});
// Set required headers for SSE
newResponse.headers.set('Content-Type', 'text/event-stream');
newResponse.headers.set('Cache-Control', 'no-cache');
newResponse.headers.set('Connection', 'keep-alive');
return newResponse;
} catch (error) {
console.error('Error in SSE proxy:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// Disable response caching
export const dynamic = 'force-dynamic';

View File

@ -0,0 +1,43 @@
const { SSEClient } = require('sse-client');
// Create a new SSE client with SSL certificate validation bypass
const client = new SSEClient('https://api.example.com/events/orders/123', {
headers: {
'X-Signed-Auth': 'your-auth-token'
},
skipSSLValidation: true, // Bypass SSL certificate validation
debug: true
});
// Listen for order state changes
client.on('orderStateChanged', (event) => {
try {
const data = event.parsedData;
console.log('Order state changed:', data);
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ended, closing SSE connection`);
client.close();
}
} catch (err) {
console.error('Error parsing order state data:', err);
}
});
// Listen for connection errors
client.on('error', (event) => {
console.error('SSE connection error:', event.detail);
});
// Connect to the SSE endpoint
client.connect();
// Handle process termination
process.on('SIGINT', () => {
console.log('Closing SSE connection');
client.close();
process.exit(0);
});
console.log('Listening for order events. Press Ctrl+C to exit.');

View File

@ -0,0 +1,57 @@
import { getSSEConnection, setupSSECleanup } from '../src';
// Set up automatic cleanup
setupSSECleanup();
// Example order tracking function
function trackOrder(orderId: string, authToken: string) {
const apiBaseUrl = 'https://api.example.com';
const sseUrl = `${apiBaseUrl}/events/orders/${orderId}`;
console.log(`Connecting to SSE endpoint: ${sseUrl}`);
// Get or create a connection for this order
const client = getSSEConnection(sseUrl, `order-${orderId}`, {
headers: {
'X-Signed-Auth': authToken
},
debug: true
});
// Listen for order state changes
client.on('orderStateChanged', (event) => {
const data = event.parsedData;
console.log('Order state changed:', data);
// Update UI with new order state
updateOrderUI(data);
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ${orderId} ended, closing SSE connection`);
client.close();
}
});
// Listen for connection errors
client.on('error', (event) => {
console.error('SSE connection error:', event.detail);
});
return client;
}
// Example function to update UI with order state
function updateOrderUI(data: any) {
// Implementation would depend on your UI framework
console.log('Updating UI with order state:', data);
}
// Example usage
const orderId = '123456';
const authToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const connection = trackOrder(orderId, authToken);
// Later, to manually close the connection
// connection.close();

View File

@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { getSSEConnection, setupSSECleanup } from 'sse-client';
// Set up SSE cleanup once at the application level
setupSSECleanup();
// Define the order state type
interface OrderState {
orderId: string;
orderStatus: number;
preparationState: {
utcStarted: string;
utcEnded: string | null;
};
deliveryState: {
utcStarted: string | null;
utcEnded: string | null;
};
// Add other properties as needed
}
// Define the order state changed message type
interface OrderStateChangedMessage {
messageType: string;
orderId: string;
prevState: OrderState;
currentState: OrderState;
}
const OrderTracker: React.FC<{ orderId: string, authToken: string }> = ({ orderId, authToken }) => {
const [orderState, setOrderState] = useState<OrderState | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// API base URL
const apiBaseUrl = 'https://api.example.com';
// SSE URL
const sseUrl = `${apiBaseUrl}/events/orders/${orderId}`;
// Get or create a connection for this order
const client = getSSEConnection(sseUrl, `order-${orderId}`, {
headers: {
'X-Signed-Auth': authToken
},
debug: true
});
// Listen for order state changes
client.on('orderStateChanged', (event) => {
try {
const data = event.parsedData as OrderStateChangedMessage;
console.log('Order state changed:', data);
// Update state with new order state
setOrderState(data.currentState);
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ${orderId} ended, closing SSE connection`);
client.close();
}
} catch (err) {
setError('Error parsing order state data');
console.error('Error parsing order state data:', err);
}
});
// Listen for connection errors
client.on('error', (event) => {
setError('SSE connection error');
console.error('SSE connection error:', event.detail);
});
// Clean up on unmount
return () => {
client.close();
};
}, [orderId, authToken]);
if (error) {
return <div className="error">{error}</div>;
}
if (!orderState) {
return <div className="loading">Loading order status...</div>;
}
return (
<div className="order-tracker">
<h2>Order #{orderState.orderId}</h2>
<div className="order-status">
Status: {getStatusText(orderState.orderStatus)}
</div>
{/* Render other order details */}
</div>
);
};
// Helper function to get status text from status code
function getStatusText(status: number): string {
switch (status) {
case 0: return 'Created';
case 10: return 'Accepted';
case 20: return 'Preparing';
case 30: return 'Ready for Pickup';
case 40: return 'Out for Delivery';
case 50: return 'Delivered';
case 1111: return 'Completed';
default: return `Unknown (${status})`;
}
}
export default OrderTracker;

View File

@ -0,0 +1,148 @@
<template>
<div class="order-tracker">
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="!orderState" class="loading">Loading order status...</div>
<div v-else>
<h2>Order #{{ orderState.orderId }}</h2>
<div class="order-status">
Status: {{ getStatusText(orderState.orderStatus) }}
</div>
<!-- Render other order details -->
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
import { getSSEConnection, setupSSECleanup } from 'sse-client';
// Set up SSE cleanup once at the application level
setupSSECleanup();
// Define the order state type
interface OrderState {
orderId: string;
orderStatus: number;
preparationState: {
utcStarted: string;
utcEnded: string | null;
};
deliveryState: {
utcStarted: string | null;
utcEnded: string | null;
};
// Add other properties as needed
}
// Define the order state changed message type
interface OrderStateChangedMessage {
messageType: string;
orderId: string;
prevState: OrderState;
currentState: OrderState;
}
export default defineComponent({
name: 'OrderTracker',
props: {
orderId: {
type: String,
required: true
},
authToken: {
type: String,
required: true
}
},
setup(props) {
const orderState = ref<OrderState | null>(null);
const error = ref<string | null>(null);
let client: any = null;
onMounted(() => {
// API base URL
const apiBaseUrl = 'https://api.example.com';
// SSE URL
const sseUrl = `${apiBaseUrl}/events/orders/${props.orderId}`;
// Get or create a connection for this order
client = getSSEConnection(sseUrl, `order-${props.orderId}`, {
headers: {
'X-Signed-Auth': props.authToken
},
debug: true
});
// Listen for order state changes
client.on('orderStateChanged', (event: any) => {
try {
const data = event.parsedData as OrderStateChangedMessage;
console.log('Order state changed:', data);
// Update state with new order state
orderState.value = data.currentState;
// If order is ended, close the connection
if (data.currentState && data.currentState.orderStatus === 1111) {
console.log(`Order ${props.orderId} ended, closing SSE connection`);
client.close();
}
} catch (err) {
error.value = 'Error parsing order state data';
console.error('Error parsing order state data:', err);
}
});
// Listen for connection errors
client.on('error', (event: any) => {
error.value = 'SSE connection error';
console.error('SSE connection error:', event.detail);
});
});
onUnmounted(() => {
// Clean up on unmount
if (client) {
client.close();
}
});
// Helper function to get status text from status code
const getStatusText = (status: number): string => {
switch (status) {
case 0: return 'Created';
case 10: return 'Accepted';
case 20: return 'Preparing';
case 30: return 'Ready for Pickup';
case 40: return 'Out for Delivery';
case 50: return 'Delivered';
case 1111: return 'Completed';
default: return `Unknown (${status})`;
}
};
return {
orderState,
error,
getStatusText
};
}
});
</script>
<style scoped>
.order-tracker {
padding: 1rem;
border: 1px solid #eee;
border-radius: 4px;
}
.error {
color: red;
}
.loading {
color: #888;
}
</style>

View File

@ -0,0 +1,15 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json',
}],
},
};

View File

@ -0,0 +1,43 @@
{
"name": "@g1/sse-client",
"version": "0.2.0",
"description": "A custom SSE client that supports headers and bypasses certificate issues",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"prepublishOnly": "npm run build"
},
"keywords": [
"sse",
"eventsource",
"server-sent-events",
"realtime"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.generation.one/GenerationOne/g1-ts-common-packages.git"
},
"publishConfig": {
"registry": "https://git.generation.one/api/packages/GenerationOne/npm/"
},
"dependencies": {
"event-source-polyfill": "^1.0.31"
},
"devDependencies": {
"@types/event-source-polyfill": "^1.0.5",
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
},
"files": [
"dist",
"README.md",
"LICENSE"
]
}

2607
packages/sse-client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
import { SSEClient } from '../sseClient';
// Mock EventSourcePolyfill
jest.mock('event-source-polyfill', () => {
return {
EventSourcePolyfill: jest.fn().mockImplementation(() => {
return {
addEventListener: jest.fn(),
close: jest.fn(),
onopen: null,
onerror: null
};
})
};
});
describe('SSEClient', () => {
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Set debug mode
(globalThis as any).__SSE_DEBUG_ENABLED = false;
});
it('should create a new SSE client', () => {
const client = new SSEClient('https://example.com/events');
expect(client).toBeDefined();
});
it('should connect to the SSE endpoint', () => {
const client = new SSEClient('https://example.com/events');
client.connect();
// EventSourcePolyfill should be called with the URL and options
expect(require('event-source-polyfill').EventSourcePolyfill).toHaveBeenCalledWith(
'https://example.com/events',
expect.objectContaining({
headers: {},
withCredentials: true
})
);
});
it('should add event listeners', () => {
const client = new SSEClient('https://example.com/events');
const listener = jest.fn();
client.on('message', listener);
client.connect();
// EventSourcePolyfill.addEventListener should be called with the event name and a function
expect(require('event-source-polyfill').EventSourcePolyfill().addEventListener)
.toHaveBeenCalledWith('message', expect.any(Function));
});
it('should close the connection', () => {
const client = new SSEClient('https://example.com/events');
client.connect();
client.close();
// EventSourcePolyfill.close should be called
expect(require('event-source-polyfill').EventSourcePolyfill().close).toHaveBeenCalled();
});
it('should handle connection errors', () => {
const client = new SSEClient('https://example.com/events', {
autoReconnect: false
});
client.connect();
// Get the error handler
const errorHandler = require('event-source-polyfill').EventSourcePolyfill().onerror;
// Simulate an error
if (errorHandler) {
errorHandler(new Error('Connection error'));
}
// No reconnection should be attempted (autoReconnect is false)
expect(require('event-source-polyfill').EventSourcePolyfill).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,11 @@
export {
SSEClient,
SSEClientOptions,
SSEConnectionManager,
getSSEConnection,
closeSSEConnection,
closeAllSSEConnections,
setupSSECleanup
} from './sseClient';
export { debugLog, debugInfo, debugWarn, debugError } from './utils/debug';

View File

@ -0,0 +1,451 @@
import { EventSourcePolyfill } from 'event-source-polyfill';
import { debugLog, debugError, debugWarn } from './utils/debug';
/**
* Configuration options for the SSE client
*/
export interface SSEClientOptions {
/**
* Headers to include in the SSE request
*/
headers?: Record<string, string>;
/**
* Whether to include credentials in the request
* @default true
*/
withCredentials?: boolean;
/**
* Timeout for heartbeat in milliseconds
* @default 21600000 (6 hours)
*/
heartbeatTimeout?: number;
/**
* Maximum number of retry attempts
* @default 10
*/
maxRetryAttempts?: number;
/**
* Initial delay for retry in milliseconds
* @default 1000
*/
initialRetryDelay?: number;
/**
* Maximum delay for retry in milliseconds
* @default 30000
*/
maxRetryDelay?: number;
/**
* Whether to automatically reconnect on error
* @default true
*/
autoReconnect?: boolean;
/**
* Debug mode
* @default false
*/
debug?: boolean;
/**
* Whether to bypass SSL certificate validation
* Only works in Node.js environments
* @default false
*/
skipSSLValidation?: boolean;
}
/**
* Default options for the SSE client
*/
const DEFAULT_OPTIONS: Required<Omit<SSEClientOptions, 'headers'>> = {
withCredentials: true,
heartbeatTimeout: 21600000, // 6 hours
maxRetryAttempts: 10,
initialRetryDelay: 1000,
maxRetryDelay: 30000,
autoReconnect: true,
debug: false,
skipSSLValidation: false
};
/**
* Helper: delay for ms
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* A custom SSE client that supports headers and bypasses certificate issues
*/
export class SSEClient {
private eventSource: EventSourcePolyfill | null = null;
private url: string;
private options: Required<SSEClientOptions>;
private retryCount = 0;
private closeRequested = false;
private eventListeners: Map<string, Set<(event: any) => void>> = new Map();
private customEventTarget: EventTarget;
/**
* Creates a new SSE client
* @param url The URL to connect to
* @param options Configuration options
*/
constructor(url: string, options: SSEClientOptions = {}) {
this.url = url;
this.options = {
...DEFAULT_OPTIONS,
headers: options.headers || {},
...options
};
// Set debug mode
if (this.options.debug) {
(globalThis as any).__SSE_DEBUG_ENABLED = true;
}
// Create a custom event target for dispatching events
this.customEventTarget = typeof window !== 'undefined'
? document.createElement('div')
: new EventTarget();
debugLog('SSEClient: Created new client for URL:', url);
}
/**
* Connects to the SSE endpoint
* @returns The SSE client instance for chaining
*/
public connect(): SSEClient {
if (this.eventSource) {
debugWarn('SSEClient: Already connected, closing existing connection');
this.close();
}
this.closeRequested = false;
this.createConnection();
return this;
}
/**
* Closes the SSE connection
*/
public close(): void {
this.closeRequested = true;
if (this.eventSource) {
debugLog('SSEClient: Closing connection');
this.eventSource.close();
this.eventSource = null;
}
}
/**
* Adds an event listener for SSE events
* @param eventName The name of the event to listen for
* @param listener The event listener function
* @returns The SSE client instance for chaining
*/
public on(eventName: string, listener: (event: any) => void): SSEClient {
if (!this.eventListeners.has(eventName)) {
this.eventListeners.set(eventName, new Set());
}
this.eventListeners.get(eventName)!.add(listener);
// If already connected, add the listener to the event source
if (this.eventSource) {
this.addEventSourceListener(eventName, listener);
}
return this;
}
/**
* Removes an event listener
* @param eventName The name of the event
* @param listener The event listener function to remove
* @returns The SSE client instance for chaining
*/
public off(eventName: string, listener: (event: any) => void): SSEClient {
const listeners = this.eventListeners.get(eventName);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.eventListeners.delete(eventName);
}
}
return this;
}
/**
* Removes all event listeners for a specific event
* @param eventName The name of the event
* @returns The SSE client instance for chaining
*/
public removeAllListeners(eventName?: string): SSEClient {
if (eventName) {
this.eventListeners.delete(eventName);
} else {
this.eventListeners.clear();
}
return this;
}
/**
* Creates a new SSE connection
*/
private createConnection(): void {
debugLog('SSEClient: Creating new connection to:', this.url);
try {
// Create connection options
const connectionOptions: any = {
headers: this.options.headers,
withCredentials: this.options.withCredentials,
heartbeatTimeout: this.options.heartbeatTimeout
};
// Add SSL bypass if requested and in Node.js environment
if (this.options.skipSSLValidation && typeof window === 'undefined') {
try {
// Only import https in Node.js environment
const https = require('https');
connectionOptions.httpsAgent = new https.Agent({ rejectUnauthorized: false });
debugLog('SSEClient: SSL certificate validation bypassed');
} catch (e) {
debugWarn('SSEClient: Failed to create https agent for SSL bypass:', e);
}
}
this.eventSource = new EventSourcePolyfill(this.url, connectionOptions);
// Set up event handlers
this.eventSource.onopen = this.handleOpen.bind(this);
this.eventSource.onerror = this.handleError.bind(this);
// Add event listeners for all registered events
for (const [eventName, listeners] of this.eventListeners.entries()) {
for (const listener of listeners) {
this.addEventSourceListener(eventName, listener);
}
}
} catch (error) {
debugError('SSEClient: Error creating connection:', error);
this.handleError(error);
}
}
/**
* Adds an event listener to the event source
* @param eventName The name of the event
* @param listener The event listener function
*/
private addEventSourceListener(eventName: string, listener: (event: any) => void): void {
if (!this.eventSource) return;
this.eventSource.addEventListener(eventName, (event: any) => {
try {
debugLog(`SSEClient: Received ${eventName} event:`, event);
// Parse the event data if it's a string
if (typeof event.data === 'string') {
try {
const parsedData = JSON.parse(event.data);
event.parsedData = parsedData;
} catch (e) {
// If parsing fails, just use the raw data
debugWarn(`SSEClient: Failed to parse event data as JSON:`, e);
}
}
// Call the listener with the event
listener(event);
} catch (error) {
debugError(`SSEClient: Error handling ${eventName} event:`, error);
}
});
}
/**
* Handles the open event
*/
private handleOpen(): void {
debugLog('SSEClient: Connection opened');
this.retryCount = 0;
// Dispatch the open event
const openEvent = new Event('open');
this.customEventTarget.dispatchEvent(openEvent);
}
/**
* Handles the error event
* @param error The error that occurred
*/
private handleError(error: any): void {
debugError('SSEClient: Connection error:', error);
// Dispatch the error event
const errorEvent = new CustomEvent('error', { detail: error });
this.customEventTarget.dispatchEvent(errorEvent);
// If close was requested, don't reconnect
if (this.closeRequested) {
debugLog('SSEClient: Not reconnecting (close requested)');
return;
}
// If auto reconnect is disabled, don't reconnect
if (!this.options.autoReconnect) {
debugLog('SSEClient: Not reconnecting (auto reconnect disabled)');
return;
}
// If max retry attempts reached, don't reconnect
if (this.retryCount >= this.options.maxRetryAttempts) {
debugError(`SSEClient: Max retry attempts (${this.options.maxRetryAttempts}) reached, not reconnecting`);
return;
}
// Reconnect with exponential backoff
this.reconnect();
}
/**
* Reconnects to the SSE endpoint with exponential backoff
*/
private async reconnect(): Promise<void> {
this.retryCount++;
// Calculate delay with exponential backoff
const delayMs = Math.min(
this.options.initialRetryDelay * Math.pow(2, this.retryCount - 1),
this.options.maxRetryDelay
);
debugLog(`SSEClient: Reconnecting in ${delayMs}ms (attempt ${this.retryCount}/${this.options.maxRetryAttempts})`);
// Wait for the delay
await delay(delayMs);
// If close was requested during the delay, don't reconnect
if (this.closeRequested) {
debugLog('SSEClient: Not reconnecting (close requested during delay)');
return;
}
// Create a new connection
this.createConnection();
}
}
/**
* Singleton pattern for SSE connections
*/
export class SSEConnectionManager {
private static instance: SSEConnectionManager;
private connections: Map<string, SSEClient> = new Map();
private constructor() {}
/**
* Gets the singleton instance
* @returns The SSE connection manager instance
*/
public static getInstance(): SSEConnectionManager {
if (!SSEConnectionManager.instance) {
SSEConnectionManager.instance = new SSEConnectionManager();
}
return SSEConnectionManager.instance;
}
/**
* Gets or creates a connection for the given ID
* @param url The URL to connect to
* @param id A unique identifier for the connection
* @param options Configuration options
* @returns The SSE client instance
*/
public getConnection(url: string, id: string, options: SSEClientOptions = {}): SSEClient {
if (this.connections.has(id)) {
debugLog(`SSEConnectionManager: Reusing existing connection for ID ${id}`);
return this.connections.get(id)!;
}
debugLog(`SSEConnectionManager: Creating new connection for ID ${id}`);
const client = new SSEClient(url, options).connect();
this.connections.set(id, client);
return client;
}
/**
* Closes a connection for the given ID
* @param id The connection ID
*/
public closeConnection(id: string): void {
if (this.connections.has(id)) {
debugLog(`SSEConnectionManager: Closing connection for ID ${id}`);
this.connections.get(id)!.close();
this.connections.delete(id);
}
}
/**
* Closes all connections
*/
public closeAllConnections(): void {
debugLog(`SSEConnectionManager: Closing all connections`);
for (const [id, client] of this.connections.entries()) {
client.close();
this.connections.delete(id);
}
}
}
/**
* Gets or creates a connection for the given ID
* @param url The URL to connect to
* @param id A unique identifier for the connection
* @param options Configuration options
* @returns The SSE client instance
*/
export function getSSEConnection(url: string, id: string, options: SSEClientOptions = {}): SSEClient {
return SSEConnectionManager.getInstance().getConnection(url, id, options);
}
/**
* Closes a connection for the given ID
* @param id The connection ID
*/
export function closeSSEConnection(id: string): void {
SSEConnectionManager.getInstance().closeConnection(id);
}
/**
* Closes all connections
*/
export function closeAllSSEConnections(): void {
SSEConnectionManager.getInstance().closeAllConnections();
}
/**
* Sets up event listeners to clean up SSE connections when the page is unloaded
*/
export function setupSSECleanup(): void {
if (typeof window === 'undefined') return;
// Clean up all SSE connections when the page is unloaded
window.addEventListener('beforeunload', () => {
debugLog('SSECleanup: Cleaning up all SSE connections before page unload');
closeAllSSEConnections();
});
}

View File

@ -0,0 +1,47 @@
/**
* Debug utility that wraps console methods and checks the global debug flag.
*/
/**
* Checks if debug mode is enabled
* @returns Whether debug mode is enabled
*/
function isDebugEnabled(): boolean {
return typeof (globalThis as any).__SSE_DEBUG_ENABLED === 'boolean'
? (globalThis as any).__SSE_DEBUG_ENABLED
: false;
}
/* eslint-disable no-console */
/**
* Logs a debug message to the console if debug mode is enabled
* @param args The arguments to log
*/
export const debugLog = (...args: any[]): void => {
if (isDebugEnabled()) console.log('[SSE]', ...args);
};
/**
* Logs an info message to the console if debug mode is enabled
* @param args The arguments to log
*/
export const debugInfo = (...args: any[]): void => {
if (isDebugEnabled()) console.info('[SSE]', ...args);
};
/**
* Logs a warning message to the console if debug mode is enabled
* @param args The arguments to log
*/
export const debugWarn = (...args: any[]): void => {
if (isDebugEnabled()) console.warn('[SSE]', ...args);
};
/**
* Logs an error message to the console if debug mode is enabled
* @param args The arguments to log
*/
export const debugError = (...args: any[]): void => {
if (isDebugEnabled()) console.error('[SSE]', ...args);
};
/* eslint-enable no-console */

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "es2018"],
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"types": ["node", "jest"]
},
"include": ["src"],
"exclude": ["node_modules", "**/*.test.ts"]
}

2617
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- 'packages/*'

115
scripts/publish-package.sh Normal file
View File

@ -0,0 +1,115 @@
#!/bin/bash
# Script to publish a package to the Gitea registry using the deploy key
# Ensure the scripts directory exists
mkdir -p scripts
# Check if pnpm is installed
if ! command -v pnpm &> /dev/null; then
echo "Error: pnpm is not installed or not in your PATH."
echo "Please install pnpm using one of the following methods:"
echo ""
echo "Using npm:"
echo " npm install -g pnpm"
echo ""
echo "Using Homebrew (macOS):"
echo " brew install pnpm"
echo ""
echo "Using Windows:"
echo " npm install -g pnpm"
echo " or"
echo " winget install pnpm"
echo ""
echo "For more installation options, visit: https://pnpm.io/installation"
exit 1
fi
# Configuration
KEY_FILE=".deploy-key"
TOKEN_FILE=".gitea-token"
GITEA_URL="https://git.generation.one"
REGISTRY_URL="$GITEA_URL/api/packages/GenerationOne/npm/"
# Check if arguments are provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <package-directory> [version-bump]"
echo "Example: $0 packages/sse-client patch"
echo "Version bump can be: patch, minor, major, or none (default: none)"
echo "To set a specific version: $0 packages/sse-client version:0.2.0"
exit 1
fi
PACKAGE_DIR="$1"
VERSION_BUMP="${2:-none}"
# Check if package directory exists
if [ ! -d "$PACKAGE_DIR" ]; then
echo "Package directory '$PACKAGE_DIR' does not exist."
exit 1
fi
# Check if token is available
if [ -f "$TOKEN_FILE" ]; then
# Read the token from file
GITEA_TOKEN=$(cat "$TOKEN_FILE")
echo "Using token from $TOKEN_FILE"
else
# Check if token is in environment variable
if [ -z "$GITEA_TOKEN" ]; then
echo "Gitea token not found."
echo "Please either:"
echo "1. Create a file named '$TOKEN_FILE' with your Gitea access token, or"
echo "2. Set the GITEA_TOKEN environment variable"
echo "You can generate a token at $GITEA_URL/user/settings/applications"
echo "Make sure the token has 'packages:write' permission."
exit 1
else
echo "Using token from GITEA_TOKEN environment variable"
fi
fi
# Navigate to the package directory
cd "$PACKAGE_DIR" || exit 1
# Bump version if requested
if [ "$VERSION_BUMP" != "none" ]; then
# Check if it's a specific version (format: version:x.y.z)
if [[ "$VERSION_BUMP" == version:* ]]; then
SPECIFIC_VERSION="${VERSION_BUMP#version:}"
echo "Setting specific version ($SPECIFIC_VERSION)..."
npm version "$SPECIFIC_VERSION" --no-git-tag-version --allow-same-version
else
echo "Bumping version ($VERSION_BUMP)..."
npm version "$VERSION_BUMP" --no-git-tag-version
fi
fi
# Determine which package manager to use (prefer pnpm, fallback to npm)
PKG_MANAGER="npm"
if command -v pnpm &> /dev/null; then
PKG_MANAGER="pnpm"
echo "Using pnpm as package manager"
else
echo "pnpm not found, falling back to npm"
fi
# Install dependencies
echo "Installing dependencies..."
$PKG_MANAGER install --no-frozen-lockfile
# Build the package
echo "Building package..."
$PKG_MANAGER run build
# Set up .npmrc for publishing
echo "Setting up .npmrc for publishing..."
echo "@g1:registry=$REGISTRY_URL" > .npmrc
echo "//git.generation.one/api/packages/GenerationOne/npm/:_authToken=$GITEA_TOKEN" >> .npmrc
echo "legacy-peer-deps=true" >> .npmrc
echo "resolution-mode=highest" >> .npmrc
# Publish the package
echo "Publishing package..."
$PKG_MANAGER publish --no-git-checks
echo "Package published successfully!"

View File

@ -0,0 +1,54 @@
#!/bin/bash
# Script to generate and configure a Gitea deploy key for this repository
# Ensure the scripts directory exists
mkdir -p scripts
# Configuration
KEY_NAME="g1-ts-common-packages-deploy-key"
KEY_FILE=".deploy-key"
KEY_FILE_PUB=".deploy-key.pub"
TOKEN_FILE=".gitea-token"
GITEA_URL="https://git.generation.one"
REPO_OWNER="GenerationOne"
REPO_NAME="g1-ts-common-packages"
# Check if keys already exist
if [ -f "$KEY_FILE" ] && [ -f "$KEY_FILE_PUB" ]; then
echo "Deploy keys already exist. Using existing keys."
else
echo "Generating new SSH key pair for deployment..."
ssh-keygen -t ed25519 -f "$KEY_FILE" -N "" -C "$KEY_NAME"
echo "SSH key pair generated."
fi
# Read the public key
PUBLIC_KEY=$(cat "$KEY_FILE_PUB")
# Check if token file exists
if [ ! -f "$TOKEN_FILE" ]; then
echo "Gitea token file not found."
echo "Please create a file named '$TOKEN_FILE' with your Gitea access token."
echo "You can generate a token at $GITEA_URL/user/settings/applications"
echo "Make sure the token has 'write:repository' permission."
exit 1
fi
# Read the token
GITEA_TOKEN=$(cat "$TOKEN_FILE")
# Add the deploy key to the repository
echo "Adding deploy key to repository..."
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\":\"$KEY_NAME\", \"key\":\"$PUBLIC_KEY\", \"read_only\":false}" \
"$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/keys"
echo ""
echo "Deploy key setup complete."
echo "The private key is stored in $KEY_FILE"
echo "The public key is stored in $KEY_FILE_PUB"
echo ""
echo "To use this key in CI/CD pipelines, add the private key as a secret."
echo "For GitHub Actions, you can add it as a repository secret named 'GITEA_DEPLOY_KEY'."