From 8c57ba0f3ef8631c7624bd30279d06b9451cf6fc Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sat, 11 Apr 2026 23:07:08 +0300 Subject: [PATCH 1/7] chore: websocket connection optimisation --- packages/javascript/src/modules/socket.ts | 124 ++++++++++++++++++---- 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 10f3fd6..2d734f8 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -3,6 +3,13 @@ import { log } from '@hawk.so/core'; import type { CatcherMessage } from '@/types'; import type { CatcherMessageType } from '@hawk.so/types'; +/** + * WebSocket close codes that represent an intentional, expected closure. + * See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +const WS_CLOSE_NORMAL = 1000; +const WS_CLOSE_GOING_AWAY = 1001; + /** * Custom WebSocket wrapper class * @@ -30,8 +37,8 @@ export default class Socket private readonly onClose: (event: CloseEvent) => void; /** - * Queue of events collected while socket is not connected - * They will be sent when connection will be established + * Queue of events collected while socket is not connected. + * They will be sent once the connection is established. */ private eventsQueue: CatcherMessage[]; @@ -51,7 +58,7 @@ export default class Socket private readonly reconnectionTimeout: number; /** - * How many time we should attempt reconnection + * How many times we should attempt reconnection */ private reconnectionAttempts: number; @@ -60,6 +67,19 @@ export default class Socket */ private pageHideHandler: () => void; + /** + * Timer that closes an idle connection after no errors have been sent + * for connectionIdleMs milliseconds. + */ + private connectionIdleTimer: ReturnType | null = null; + + /** + * How long (ms) to keep the connection open after the last error was sent. + * Errors often come in bursts, so holding the socket briefly avoids + * the overhead of opening a new connection for each one. + */ + private readonly connectionIdleMs: number; + /** * Creates new Socket instance. Setup initial socket params. * @@ -75,6 +95,7 @@ export default class Socket onOpen = (): void => {}, reconnectionAttempts = 5, reconnectionTimeout = 10000, // 10 * 1000 ms = 10 sec + connectionIdleMs = 10000, // 10 sec — close connection if no new errors arrive }) { this.url = collectorEndpoint; this.onMessage = onMessage; @@ -82,6 +103,7 @@ export default class Socket this.onOpen = onOpen; this.reconnectionTimeout = reconnectionTimeout; this.reconnectionAttempts = reconnectionAttempts; + this.connectionIdleMs = connectionIdleMs; this.pageHideHandler = () => { this.close(); @@ -90,16 +112,10 @@ export default class Socket this.eventsQueue = []; this.ws = null; - this.init() - .then(() => { - /** - * Send queued events if exists - */ - this.sendQueue(); - }) - .catch((error) => { - log('WebSocket error', 'error', error); - }); + /** + * Connection is not opened eagerly — it is created on the first send() + * and closed automatically after connectionIdleMs of inactivity. + */ } /** @@ -119,6 +135,8 @@ export default class Socket switch (this.ws.readyState) { case WebSocket.OPEN: + this.resetIdleTimer(); + return this.ws.send(JSON.stringify(message)); case WebSocket.CLOSED: @@ -151,6 +169,20 @@ export default class Socket */ private init(): Promise { return new Promise((resolve, reject) => { + /** + * Detach handlers and close the previous socket before opening a new one. + * Without this, the old connection stays open and its onclose/onerror + * handlers keep firing, causing duplicate reconnect attempts and log noise. + */ + if (this.ws !== null) { + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + this.ws.close(); + this.ws = null; + } + this.ws = new WebSocket(this.url); /** @@ -168,8 +200,32 @@ export default class Socket this.ws.onclose = (event: CloseEvent): void => { this.destroyListeners(); - if (typeof this.onClose === 'function') { - this.onClose(event); + /** + * Code 1000 = Normal Closure (intentional), 1001 = Going Away (page unload/navigation). + * These are expected and should not be reported as a lost connection. + * Any other code (e.g. 1006 = Abnormal Closure from idle timeout or infrastructure drop) + * means the connection was lost unexpectedly — notify and reconnect if there are + * queued events waiting to be sent. + */ + const isExpectedClose = [WS_CLOSE_NORMAL, WS_CLOSE_GOING_AWAY].includes(event.code); + + if (!isExpectedClose) { + /** + * Cancel the idle timer — it belongs to the now-dead connection. + * A reconnect will set a fresh timer once the new connection is sending. + */ + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); + this.connectionIdleTimer = null; + } + + if (typeof this.onClose === 'function') { + this.onClose(event); + } + + if (this.eventsQueue.length > 0) { + void this.reconnect(); + } } }; @@ -195,20 +251,46 @@ export default class Socket } /** - * Closes socket connection + * Closes socket connection and cancels any pending idle timer */ private close(): void { - if (this.ws) { - this.ws.close(); - this.ws = null; + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); + this.connectionIdleTimer = null; + } + + if (this.ws === null) { + return; } + + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + this.ws.close(); + this.ws = null; + } + + /** + * Resets the idle close timer. + * Called after each successful send so the connection stays open + * for connectionIdleMs after the last error in a burst. + */ + private resetIdleTimer(): void { + if (this.connectionIdleTimer !== null) { + clearTimeout(this.connectionIdleTimer); + } + + this.connectionIdleTimer = setTimeout(() => { + this.connectionIdleTimer = null; + this.close(); + }, this.connectionIdleMs); } /** * Tries to reconnect to the server for specified number of times with the interval * - * @param {boolean} [isForcedCall] - call function despite on timer - * @returns {Promise} + * @param isForcedCall - call function despite on timer */ private async reconnect(isForcedCall = false): Promise { if (this.reconnectionTimer && !isForcedCall) { From 4dcdca45407ae60d89756defe9945faa0643d5df Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sat, 11 Apr 2026 23:16:06 +0300 Subject: [PATCH 2/7] fix tests --- packages/javascript/src/modules/socket.ts | 6 ++++++ packages/javascript/tests/socket.test.ts | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 2d734f8..a10eaef 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -269,6 +269,12 @@ export default class Socket this.ws.onmessage = null; this.ws.close(); this.ws = null; + + /** + * onclose is nulled above so it won't fire — call destroyListeners() directly + * to ensure the pagehide listener is always removed on explicit close. + */ + this.destroyListeners(); } /** diff --git a/packages/javascript/tests/socket.test.ts b/packages/javascript/tests/socket.test.ts index ef81a3e..33a3538 100644 --- a/packages/javascript/tests/socket.test.ts +++ b/packages/javascript/tests/socket.test.ts @@ -52,15 +52,18 @@ describe('Socket', () => { this.onmessage = undefined; webSocket = this; }); + patchWebSocketMockConstructor(WebSocketConstructor); globalThis.WebSocket = WebSocketConstructor; const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - // initialize socket and open fake websocket connection + // Connection is lazy — trigger it via send() const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + const initSendPromise = socket.send({ foo: 'init' } as CatcherMessage); webSocket.readyState = WebSocket.OPEN; webSocket.onopen?.(new Event('open')); + await initSendPromise; // capture pagehide handler to verify it's properly removed const pagehideCall = addEventListenerSpy.mock.calls.find(([event]) => event === 'pagehide'); @@ -127,14 +130,19 @@ describe('Socket — events queue after connection loss', () => { reconnectionTimeout: 10, }); + // Connection is lazy — trigger it via send() so ws1 is created + const payload = { type: 'errors/javascript', title: 'queued-after-drop' } as unknown as CatcherMessage<'errors/javascript'>; + const firstSendPromise = socket.send(payload); + const ws1 = sockets[0]; + expect(ws1).toBeDefined(); ws1.readyState = WebSocket.OPEN; ws1.onopen?.(new Event('open')); - await Promise.resolve(); + await firstSendPromise; + // Simulate connection drop (readyState only, no onclose — tests the CLOSED branch in send()) ws1.readyState = WebSocket.CLOSED; - const payload = { type: 'errors/javascript', title: 'queued-after-drop' } as unknown as CatcherMessage<'errors/javascript'>; const sendPromise = socket.send(payload); const ws2 = sockets[1]; @@ -157,10 +165,12 @@ describe('Socket — events queue after connection loss', () => { const WebSocketConstructor = mockWebSocketFactory(sockets, closeSpy); globalThis.WebSocket = WebSocketConstructor as unknown as typeof WebSocket; + // Connection is lazy — trigger it via send() so sockets[0] is created const socket = new Socket({ collectorEndpoint: MOCK_WEBSOCKET_URL }); + const initSendPromise = socket.send({ foo: 'init' } as CatcherMessage); sockets[0].readyState = WebSocket.OPEN; sockets[0].onopen?.(new Event('open')); - await Promise.resolve(); + await initSendPromise; window.dispatchEvent(new Event('pagehide')); From 157453eda6180f20749657a278422fb92f4e9703 Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sat, 11 Apr 2026 23:33:03 +0300 Subject: [PATCH 3/7] review changes: detaching and fixing enqueue --- packages/javascript/src/modules/socket.ts | 59 ++++++++++++----------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index a10eaef..0ac9c42 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -124,12 +124,13 @@ export default class Socket * @param message - event data in Hawk Format */ public async send(message: CatcherMessage): Promise { - if (this.ws === null) { - this.eventsQueue.push(message); + this.eventsQueue.push(message); + if (this.ws === null) { await this.init(); - this.sendQueue(); + } + if (this.ws === null) { return; } @@ -137,16 +138,14 @@ export default class Socket case WebSocket.OPEN: this.resetIdleTimer(); - return this.ws.send(JSON.stringify(message)); + return this.sendQueue(); case WebSocket.CLOSED: - this.eventsQueue.push(message); - return this.reconnect(); case WebSocket.CONNECTING: case WebSocket.CLOSING: - this.eventsQueue.push(message); + break; } } @@ -169,20 +168,7 @@ export default class Socket */ private init(): Promise { return new Promise((resolve, reject) => { - /** - * Detach handlers and close the previous socket before opening a new one. - * Without this, the old connection stays open and its onclose/onerror - * handlers keep firing, causing duplicate reconnect attempts and log noise. - */ - if (this.ws !== null) { - this.ws.onopen = null; - this.ws.onclose = null; - this.ws.onerror = null; - this.ws.onmessage = null; - this.ws.close(); - this.ws = null; - } - + this.detachSocket(); this.ws = new WebSocket(this.url); /** @@ -259,6 +245,15 @@ export default class Socket this.connectionIdleTimer = null; } + this.detachSocket(); + } + + /** + * Detach handlers and close the previous socket before opening a new one. + * Without this, the old connection stays open and its onclose/onerror + * handlers keep firing, causing duplicate reconnect attempts and log noise. + */ + private detachSocket(): void { if (this.ws === null) { return; } @@ -308,8 +303,9 @@ export default class Socket try { await this.init(); - log('Successfully reconnected.', 'info'); - this.sendQueue(); + log('Successfully reconnected. Sending queued events...', 'info'); + + return this.sendQueue(); } catch (error) { this.reconnectionAttempts--; @@ -324,9 +320,15 @@ export default class Socket } /** - * Sends all queued events one-by-one + * Sends all queued events directly via the WebSocket. + * Bypasses send() intentionally — send() always enqueues first, + * so calling it here would cause infinite recursion. */ private sendQueue(): void { + if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) { + return; + } + while (this.eventsQueue.length) { const event = this.eventsQueue.shift(); @@ -334,10 +336,11 @@ export default class Socket continue; } - this.send(event) - .catch((sendingError) => { - log('WebSocket sending error', 'error', sendingError); - }); + try { + this.ws.send(JSON.stringify(event)); + } catch (sendingError) { + log('WebSocket sending error', 'error', sendingError); + } } } } From e176fe3540deaf237e7b71ed4b4a9e76a3042b1e Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sat, 11 Apr 2026 23:48:17 +0300 Subject: [PATCH 4/7] use singleflight for ws connection --- packages/javascript/src/modules/socket.ts | 21 +++++++++++++----- .../javascript/src/utils/single-flight.ts | 22 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 packages/javascript/src/utils/single-flight.ts diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 0ac9c42..10f1555 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -2,6 +2,7 @@ import type { Transport } from '@hawk.so/core'; import { log } from '@hawk.so/core'; import type { CatcherMessage } from '@/types'; import type { CatcherMessageType } from '@hawk.so/types'; +import { singleFlight } from '../utils/single-flight'; /** * WebSocket close codes that represent an intentional, expected closure. @@ -80,6 +81,12 @@ export default class Socket */ private readonly connectionIdleMs: number; + /** + * Deduplicates concurrent openConnection() calls — all callers share the + * same in-flight Promise so only one WebSocket is ever created at a time. + */ + private readonly initOnce: () => Promise; + /** * Creates new Socket instance. Setup initial socket params. * @@ -111,6 +118,7 @@ export default class Socket this.eventsQueue = []; this.ws = null; + this.initOnce = singleFlight(() => this.openConnection()); /** * Connection is not opened eagerly — it is created on the first send() @@ -127,7 +135,7 @@ export default class Socket this.eventsQueue.push(message); if (this.ws === null) { - await this.init(); + await this.initOnce(); } if (this.ws === null) { @@ -136,8 +144,6 @@ export default class Socket switch (this.ws.readyState) { case WebSocket.OPEN: - this.resetIdleTimer(); - return this.sendQueue(); case WebSocket.CLOSED: @@ -164,9 +170,10 @@ export default class Socket } /** - * Create new WebSocket connection and setup socket event listeners + * Create new WebSocket connection and setup socket event listeners. + * Always call initOnce() instead — it deduplicates concurrent calls. */ - private init(): Promise { + private openConnection(): Promise { return new Promise((resolve, reject) => { this.detachSocket(); this.ws = new WebSocket(this.url); @@ -301,7 +308,7 @@ export default class Socket this.reconnectionTimer = null; try { - await this.init(); + await this.initOnce(); log('Successfully reconnected. Sending queued events...', 'info'); @@ -329,6 +336,8 @@ export default class Socket return; } + this.resetIdleTimer(); + while (this.eventsQueue.length) { const event = this.eventsQueue.shift(); diff --git a/packages/javascript/src/utils/single-flight.ts b/packages/javascript/src/utils/single-flight.ts new file mode 100644 index 0000000..0b54eba --- /dev/null +++ b/packages/javascript/src/utils/single-flight.ts @@ -0,0 +1,22 @@ +/** + * Returns a version of the given async function where concurrent calls + * are merged — all callers share the same in-flight Promise rather than + * starting independent executions. Once the Promise settles, the next + * call starts fresh. + * + * @param fn - The async function to deduplicate + * @returns {Function} A wrapped version of fn that never runs concurrently with itself + */ +export function singleFlight(fn: () => Promise): () => Promise { + let inFlight: Promise | null = null; + + return (): Promise => { + if (inFlight === null) { + inFlight = fn().finally(() => { + inFlight = null; + }); + } + + return inFlight; + }; +} From 41c82cae287f86254c762147e3efc6df395b81e4 Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sun, 12 Apr 2026 00:01:03 +0300 Subject: [PATCH 5/7] minor enhancements --- packages/javascript/src/modules/socket.ts | 4 ---- packages/javascript/src/utils/single-flight.ts | 8 +++----- packages/javascript/tests/socket.test.ts | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index 10f1555..bba6e87 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -148,10 +148,6 @@ export default class Socket case WebSocket.CLOSED: return this.reconnect(); - - case WebSocket.CONNECTING: - case WebSocket.CLOSING: - break; } } diff --git a/packages/javascript/src/utils/single-flight.ts b/packages/javascript/src/utils/single-flight.ts index 0b54eba..2200a80 100644 --- a/packages/javascript/src/utils/single-flight.ts +++ b/packages/javascript/src/utils/single-flight.ts @@ -1,10 +1,8 @@ /** - * Returns a version of the given async function where concurrent calls - * are merged — all callers share the same in-flight Promise rather than - * starting independent executions. Once the Promise settles, the next - * call starts fresh. + * Wraps an async function so that concurrent calls share the same in-flight + * Promise. Once the Promise settles, the next call starts fresh. * - * @param fn - The async function to deduplicate + * @param fn - The async function to guard against concurrent execution * @returns {Function} A wrapped version of fn that never runs concurrently with itself */ export function singleFlight(fn: () => Promise): () => Promise { diff --git a/packages/javascript/tests/socket.test.ts b/packages/javascript/tests/socket.test.ts index 33a3538..9bc3304 100644 --- a/packages/javascript/tests/socket.test.ts +++ b/packages/javascript/tests/socket.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import Socket from '../src/modules/socket'; import type { CatcherMessage } from '@hawk.so/types'; From c2c8cb6900de61c60d89d4a296f88ff6b7525f6e Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Sun, 12 Apr 2026 00:10:48 +0300 Subject: [PATCH 6/7] remove redundant reconnection method --- packages/javascript/src/modules/socket.ts | 85 ++++------------------- 1 file changed, 13 insertions(+), 72 deletions(-) diff --git a/packages/javascript/src/modules/socket.ts b/packages/javascript/src/modules/socket.ts index bba6e87..692da78 100644 --- a/packages/javascript/src/modules/socket.ts +++ b/packages/javascript/src/modules/socket.ts @@ -48,21 +48,6 @@ export default class Socket */ private ws: WebSocket | null; - /** - * Reconnection tryings Timeout - */ - private reconnectionTimer: unknown; - - /** - * Time between reconnection attempts - */ - private readonly reconnectionTimeout: number; - - /** - * How many times we should attempt reconnection - */ - private reconnectionAttempts: number; - /** * Page hide event handler reference (for removal) */ @@ -94,22 +79,18 @@ export default class Socket */ constructor({ collectorEndpoint, - // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars - onMessage = (message: MessageEvent): void => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onMessage = (_message: MessageEvent): void => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function onClose = (): void => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function onOpen = (): void => {}, - reconnectionAttempts = 5, - reconnectionTimeout = 10000, // 10 * 1000 ms = 10 sec connectionIdleMs = 10000, // 10 sec — close connection if no new errors arrive }) { this.url = collectorEndpoint; this.onMessage = onMessage; this.onClose = onClose; this.onOpen = onOpen; - this.reconnectionTimeout = reconnectionTimeout; - this.reconnectionAttempts = reconnectionAttempts; this.connectionIdleMs = connectionIdleMs; this.pageHideHandler = () => { @@ -134,20 +115,16 @@ export default class Socket public async send(message: CatcherMessage): Promise { this.eventsQueue.push(message); - if (this.ws === null) { - await this.initOnce(); + if (this.ws !== null && this.ws.readyState === WebSocket.CLOSED) { + this.closeAndDetachSocket(); } if (this.ws === null) { - return; + await this.initOnce(); } - switch (this.ws.readyState) { - case WebSocket.OPEN: - return this.sendQueue(); - - case WebSocket.CLOSED: - return this.reconnect(); + if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) { + this.sendQueue(); } } @@ -171,7 +148,7 @@ export default class Socket */ private openConnection(): Promise { return new Promise((resolve, reject) => { - this.detachSocket(); + this.closeAndDetachSocket(); this.ws = new WebSocket(this.url); /** @@ -193,15 +170,14 @@ export default class Socket * Code 1000 = Normal Closure (intentional), 1001 = Going Away (page unload/navigation). * These are expected and should not be reported as a lost connection. * Any other code (e.g. 1006 = Abnormal Closure from idle timeout or infrastructure drop) - * means the connection was lost unexpectedly — notify and reconnect if there are - * queued events waiting to be sent. + * means the connection was lost unexpectedly. */ const isExpectedClose = [WS_CLOSE_NORMAL, WS_CLOSE_GOING_AWAY].includes(event.code); if (!isExpectedClose) { /** * Cancel the idle timer — it belongs to the now-dead connection. - * A reconnect will set a fresh timer once the new connection is sending. + * A fresh timer will be set once the next send() opens a new connection. */ if (this.connectionIdleTimer !== null) { clearTimeout(this.connectionIdleTimer); @@ -211,10 +187,6 @@ export default class Socket if (typeof this.onClose === 'function') { this.onClose(event); } - - if (this.eventsQueue.length > 0) { - void this.reconnect(); - } } }; @@ -248,15 +220,15 @@ export default class Socket this.connectionIdleTimer = null; } - this.detachSocket(); + this.closeAndDetachSocket(); } /** - * Detach handlers and close the previous socket before opening a new one. + * Closes the WebSocket and nulls all event handlers before releasing the reference. * Without this, the old connection stays open and its onclose/onerror * handlers keep firing, causing duplicate reconnect attempts and log noise. */ - private detachSocket(): void { + private closeAndDetachSocket(): void { if (this.ws === null) { return; } @@ -291,37 +263,6 @@ export default class Socket }, this.connectionIdleMs); } - /** - * Tries to reconnect to the server for specified number of times with the interval - * - * @param isForcedCall - call function despite on timer - */ - private async reconnect(isForcedCall = false): Promise { - if (this.reconnectionTimer && !isForcedCall) { - return; - } - - this.reconnectionTimer = null; - - try { - await this.initOnce(); - - log('Successfully reconnected. Sending queued events...', 'info'); - - return this.sendQueue(); - } catch (error) { - this.reconnectionAttempts--; - - if (this.reconnectionAttempts === 0) { - return; - } - - this.reconnectionTimer = setTimeout(() => { - void this.reconnect(true); - }, this.reconnectionTimeout); - } - } - /** * Sends all queued events directly via the WebSocket. * Bypasses send() intentionally — send() always enqueues first, From 8a0320b384dcd93cef94bfcce1252c7390b86314 Mon Sep 17 00:00:00 2001 From: Murod Khaydarov Date: Mon, 13 Apr 2026 23:14:25 +0300 Subject: [PATCH 7/7] create an gh action to release branch --- .github/workflows/npm-publish-canary.yml | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/npm-publish-canary.yml diff --git a/.github/workflows/npm-publish-canary.yml b/.github/workflows/npm-publish-canary.yml new file mode 100644 index 0000000..3dfb66d --- /dev/null +++ b/.github/workflows/npm-publish-canary.yml @@ -0,0 +1,33 @@ +name: Publish canary package to NPM + +on: + push: + branches-ignore: + - master + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + registry-url: https://registry.npmjs.org/ + - run: corepack enable + - run: yarn + - run: yarn build:all + - name: Set canary version + run: | + BRANCH=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9-]/-/g' | tr '[:upper:]' '[:lower:]') + SHA=$(echo "${{ github.sha }}" | cut -c1-7) + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('packages/javascript/package.json', 'utf8')); + pkg.version = pkg.version + '-' + '$BRANCH' + '.' + '$SHA'; + fs.writeFileSync('packages/javascript/package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log('Publishing version: ' + pkg.version); + " + - run: yarn workspace @hawk.so/javascript npm publish --access=public --tag canary + env: + YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}