From 9c08bb1e9081ce35edb44f268d3063d4699351a2 Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Sat, 25 Oct 2025 12:54:51 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20rework=20subscription?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 14 +- src/router.ts | 154 +++++++------- src/sofa.ts | 46 ++++ src/subscriptions.ts | 412 +++++++++++++++++++----------------- src/subscriptions_old.ts | 346 ++++++++++++++++++++++++++++++ tests/subscriptions.spec.ts | 61 +++++- yarn.lock | 70 ++++++ 7 files changed, 828 insertions(+), 275 deletions(-) create mode 100644 src/subscriptions_old.ts diff --git a/package.json b/package.json index dd2c209d..b4d78f2f 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,13 @@ "dependencies": { "@graphql-tools/utils": "^10.0.0", "@whatwg-node/fetch": "^0.10.0", - "fets": "^0.8.0", "ansi-colors": "^4.1.3", + "fets": "^0.8.0", "openapi-types": "^12.1.0", "param-case": "^3.0.4", - "title-case": "^3.0.3", "qs": "^6.11.2", + "readable-stream": "4.7.0", + "title-case": "^3.0.3", "tslib": "^2.5.0" }, "scripts": { @@ -69,18 +70,19 @@ "release": "yarn build && changeset publish" }, "devDependencies": { - "@changesets/changelog-github": "0.5.1", - "@changesets/cli": "2.29.5", "@babel/core": "7.28.0", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/preset-env": "7.28.0", "@babel/preset-typescript": "7.27.1", + "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "2.29.5", "@types/express": "5.0.3", "@types/jest": "30.0.0", "@types/node": "24.2.0", + "@types/qs": "6.14.0", + "@types/readable-stream": "4.0.22", "@types/swagger-ui-dist": "3.30.6", "@types/yamljs": "0.2.34", - "@types/qs": "6.14.0", "babel-jest": "30.0.5", "bob-the-bundler": "7.0.1", "chalk": "^5.4.1", @@ -111,4 +113,4 @@ "access": "public" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" -} \ No newline at end of file +} diff --git a/src/router.ts b/src/router.ts index c459ba26..6d12370e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -23,7 +23,7 @@ import { convertName } from './common.js'; import { parseVariable } from './parse.js'; import { type StartSubscriptionEvent, - SubscriptionManager, + createSubscriptionManager, } from './subscriptions.js'; import { logger } from './logger.js'; import { @@ -138,7 +138,6 @@ export function createSofaRouter(sofa: Sofa) { const queryType = sofa.schema.getQueryType(); const mutationType = sofa.schema.getMutationType(); - const subscriptionManager = new SubscriptionManager(sofa); if (queryType) { Object.keys(queryType.getFields()).forEach((fieldName) => { @@ -152,82 +151,85 @@ export function createSofaRouter(sofa: Sofa) { }); } - router.route({ - path: '/webhook', - method: 'POST', - async handler(request, serverContext) { - const { subscription, variables, url }: StartSubscriptionEvent = - await request.json(); - try { - const sofaContext: DefaultSofaServerContext = Object.assign( - serverContext, - { - request, - } - ); - const result = await subscriptionManager.start( - { - subscription, - variables, - url, - }, - sofaContext - ); - return Response.json(result); - } catch (error) { - return Response.json(error, { - status: 500, - statusText: 'Subscription failed' as any, - }); - } - }, - }); + if (sofa.schema.getSubscriptionType()) { + const subscriptionManager = createSubscriptionManager(sofa); + router.route({ + path: '/webhook', + method: 'POST', + async handler(request, serverContext) { + const { subscription, variables, url }: StartSubscriptionEvent = + await request.json(); + try { + const sofaContext: DefaultSofaServerContext = Object.assign( + serverContext, + { + request, + } + ); + const result = await subscriptionManager.start( + { + subscription, + variables, + url, + }, + sofaContext + ); + return Response.json(result); + } catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed' as any, + }); + } + }, + }); - router.route({ - path: '/webhook/:id', - method: 'POST', - async handler(request, serverContext) { - const id = request.params?.id!; - const body = await request.json(); - const variables: any = body.variables; - try { - const sofaContext = Object.assign(serverContext, { - request, - }); - const contextValue = await sofa.contextFactory(sofaContext); - const result = await subscriptionManager.update( - { - id, - variables, - }, - contextValue - ); - return Response.json(result); - } catch (error) { - return Response.json(error, { - status: 500, - statusText: 'Subscription failed to update' as any, - }); - } - }, - }); + router.route({ + path: '/webhook/:id', + method: 'POST', + async handler(request, serverContext) { + const id = request.params?.id!; + const body = await request.json(); + const variables: any = body.variables; + try { + const sofaContext = Object.assign(serverContext, { + request, + }); + const contextValue = await sofa.contextFactory(sofaContext); + const result = await subscriptionManager.update( + { + id, + variables, + }, + contextValue + ); + return Response.json(result); + } catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed to update' as any, + }); + } + }, + }); - router.route({ - path: '/webhook/:id', - method: 'DELETE', - async handler(request) { - const id = request.params?.id!; - try { - const result = await subscriptionManager.stop(id); - return Response.json(result); - } catch (error) { - return Response.json(error, { - status: 500, - statusText: 'Subscription failed to stop' as any, - }); - } - }, - }); + router.route({ + path: '/webhook/:id', + method: 'DELETE', + async handler(request) { + const id = request.params?.id!; + try { + const result = await subscriptionManager.stop(id); + return Response.json(result); + } catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed to stop' as any, + }); + } + }, + }); + } return router; } diff --git a/src/sofa.ts b/src/sofa.ts index a34eed33..cde105fc 100644 --- a/src/sofa.ts +++ b/src/sofa.ts @@ -61,6 +61,50 @@ export interface SofaConfig { openAPI?: RouterOpenAPIOptions; swaggerUI?: RouterSwaggerUIOptions; + + /** + * Webhook configuration for subscriptions. + */ + webhooks?: { + /** + * Maximum lifetime of a subscription webhook in seconds. + * After this time, the subscription will be automatically terminated. + * Default is never. + */ + maxSubscriptionWebhookLifetimeSeconds?: number; + /** + * Message sent to the webhook URL upon subscription termination. + * Can be a boolean, string, or a function that returns an object with a reason. + * If set to `false`, no message will be sent. + * If set to `true`, a default message will be sent. + * If a string is provided, it will be used as the reason for termination. + * If a function is provided, it will be called with the reason and should return an object. + * The function can also return more fields than just reason, which will then get attached to the message. Useful for e.g. timestamps. + * Default is to send no message. + * + * The termination reason will be sent in the extensions field of the payload as follows: + * ```json + * { + * "extensions": { + * "webhook": { + * "termination": { + * "reason": "Max subscription lifetime reached (60s)" + * } + * } + * } + * } + * ``` + */ + terminationMessage?: + | boolean + | string + | ((reason: string) => { reason: string }); + /** + * Timeout for webhook requests in seconds. + * Default is 5 seconds. + */ + timeoutSeconds?: number; + }; } export interface Sofa { @@ -79,6 +123,8 @@ export interface Sofa { openAPI?: RouterOpenAPIOptions; swaggerUI?: RouterSwaggerUIOptions; + + webhooks?: SofaConfig['webhooks']; } export function createSofa(config: SofaConfig): Sofa { diff --git a/src/subscriptions.ts b/src/subscriptions.ts index c2354404..0ab3493f 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -12,29 +12,11 @@ import type { Sofa } from './sofa.js'; import { getOperationInfo } from './ast.js'; import { parseVariable } from './parse.js'; import { logger } from './logger.js'; +import { ObjMap } from 'graphql/jsutils/ObjMap.js'; +import { pipeline, Readable, Writable } from 'readable-stream'; -function isAsyncIterable(obj: any): obj is AsyncIterable { - return typeof obj[Symbol.asyncIterator] === 'function'; -} - -// To start subscription: -// - an url that Sofa should trigger -// - name of a subscription -// - variables if needed -// - some sort of an auth token -// - Sofa should return a unique id of that subscription -// - respond with OK 200 - -// To stop subscription -// - an id is required -// - respond with OK 200 - -// To update subscription -// - an id is required -// - new set of variables - -export type ID = string; -export type SubscriptionFieldName = string; +type SubscriptionFieldName = string; +type ID = string; export interface StartSubscriptionEvent { subscription: SubscriptionFieldName; @@ -47,10 +29,6 @@ export interface UpdateSubscriptionEvent { variables: any; } -export interface StopSubscriptionResponse { - id: ID; -} - interface BuiltOperation { operationName: string; document: DocumentNode; @@ -58,115 +36,67 @@ interface BuiltOperation { } interface StoredClient { - name: SubscriptionFieldName; - url: string; - iterator: AsyncIterator; + subscriptionName: SubscriptionFieldName; + rx: Readable; + tx: Writable; } -export class SubscriptionManager { - private operations = new Map(); - private clients = new Map(); - - constructor(private sofa: Sofa) { - this.buildOperations(); - } - - public async start( - event: StartSubscriptionEvent, - contextValue: ContextValue - ) { - const id = crypto.randomUUID(); - const name = event.subscription; - - if (!this.operations.has(name)) { - throw new Error(`Subscription '${name}' is not available`); - } - - logger.info(`[Subscription] Start ${id}`, event); - - const result = await this.execute({ - id, - name, - url: event.url, - variables: event.variables, - contextValue, - }); +function isAsyncIterable(obj: any): obj is AsyncIterable { + return typeof obj[Symbol.asyncIterator] === 'function'; +} - if (typeof result !== 'undefined') { - return result; - } +export function createSubscriptionManager(sofa: Sofa) { + const subscription = sofa.schema.getSubscriptionType(); - return { id }; + if (!subscription) { + throw new Error('Schema does not have subscription type'); } - public async stop(id: ID): Promise { - logger.info(`[Subscription] Stop ${id}`); - - if (!this.clients.has(id)) { - throw new Error(`Subscription with ID '${id}' does not exist`); - } - - const execution = this.clients.get(id)!; - - if (execution.iterator.return) { - execution.iterator.return(); - } + const fieldMap = subscription.getFields(); + const operations = new Map(); + const clients = new Map(); + + for (const field in fieldMap) { + const operationNode = buildOperationNodeForField({ + kind: 'subscription' as OperationTypeNode, + field, + schema: sofa.schema, + models: sofa.models, + ignore: sofa.ignore, + circularReferenceDepth: sofa.depthLimit, + }); + const document: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [operationNode], + }; - this.clients.delete(id); + const { variables, name: operationName } = getOperationInfo(document)!; - return { id }; + operations.set(field, { + operationName, + document, + variables, + }); } - public async update( - event: UpdateSubscriptionEvent, + const readableStreamFromOperationCall = async ( + id: ID, + subscriptionName: SubscriptionFieldName, + event: StartSubscriptionEvent | UpdateSubscriptionEvent, contextValue: ContextValue - ) { - const { variables, id } = event; - - logger.info(`[Subscription] Update ${id}`, event); - - if (!this.clients.has(id)) { - throw new Error(`Subscription with ID '${id}' does not exist`); + ) => { + const operation = operations.get(subscriptionName); + if (!operation) { + throw new Error(`Subscription '${subscriptionName}' is not available`); } - const { name: subscription, url } = this.clients.get(id)!; - - this.stop(id); - - return this.start( - { - url, - subscription, - variables, - }, - contextValue - ); - } - - private async execute({ - id, - name, - url, - variables, - contextValue, - }: { - id: ID; - name: SubscriptionFieldName; - url: string; - variables: Record; - contextValue: ContextValue; - }) { - const { - document, - operationName, - variables: variableNodes, - } = this.operations.get(name)!; + logger.info(`[Subscription] Start ${id}`, event); - const variableValues = variableNodes.reduce((values, variable) => { + const variableValues = operation.variables.reduce((values, variable) => { const value = parseVariable({ - value: variables[variable.variable.name.value], + value: event.variables[variable.variable.name.value], variable, - schema: this.sofa.schema, + schema: sofa.schema, }); if (typeof value === 'undefined') { @@ -179,97 +109,197 @@ export class SubscriptionManager { }; }, {}); - const execution = await this.sofa.subscribe({ - schema: this.sofa.schema, - document, - operationName, + const subscriptionIterable = await sofa.subscribe({ + schema: sofa.schema, + document: operation.document, + operationName: operation.operationName, variableValues, contextValue, }); - if (isAsyncIterable(execution)) { - // successful + if (!isAsyncIterable(subscriptionIterable)) { + throw subscriptionIterable as ExecutionResult; + } - // add execution to clients - this.clients.set(id, { - name, - url, - iterator: execution as any, - }); + // In case we do not get yielded an actual readable stream + // we want to convert it to one + return Readable.from(subscriptionIterable, { + objectMode: true, + highWaterMark: 100, + }); + }; + + const mergeTxRxStreams = (id: ID, rx: Readable, tx: Writable) => { + pipeline(rx, tx, (err) => { + if (err) { + logger.error(`[Subscription] Pipeline error on ${id}: ${err.message}`); + stop(id, 'Subscription pipeline errored out'); + } + }); + + rx.on('data', (chunk) => { + console.log('rx data', chunk); + }); + rx.on('close', () => { + stop(id, 'Subscription closed by client'); + }); + rx.on('end', () => { + stop(id, 'Subscription completed, no further data available'); + }); + rx.on('error', (err) => { + logger.error(`[Subscription] Error on ${id}: ${err.message}`); + stop(id, 'Subscription errored out (rx)'); + }); + }; - // success - (async () => { - for await (const result of execution) { - await this.sendData({ - id, - result, + const start = async ( + event: StartSubscriptionEvent, + contextValue: ContextValue + ) => { + const id = crypto.randomUUID(); + const subscriptionName = event.subscription; + + const rx = await readableStreamFromOperationCall( + id, + subscriptionName, + event, + contextValue + ); + + const tx = new Writable({ + objectMode: true, + highWaterMark: 100, + async write(message, _encoding, callback) { + logger.info(`[Subscription] Trigger ${id}`); + + try { + const response = await fetch(event.url, { + method: 'POST', + body: JSON.stringify(message), + headers: { + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout( + (sofa.webhooks?.timeoutSeconds || 5) * 1000 + ), }); + + if (!response.ok) { + logger.error( + `[Subscription] Failed to send data for ${id} to ${event.url}: ${response.status} ${response.statusText}` + ); + callback( + new Error( + `Failed to send data for ${id} to ${event.url}: ${response.status} ${response.statusText}` + ) + ); + return; + } + + response.body?.cancel(); // We don't care about the response body but want to free up resources + callback(); + } catch (err) { + callback(err instanceof Error ? err : new Error(String(err))); } - })().then( - () => { - // completes - this.clients.delete(id); - }, - (e) => { - logger.info(`Subscription #${id} closed`); - logger.error(e); - this.clients.delete(id); - } - ); - } else { - return execution as ExecutionResult; - } - } + }, + }); - private async sendData({ id, result }: { id: ID; result: any }) { - if (!this.clients.has(id)) { - throw new Error(`Subscription with ID '${id}' does not exist`); - } + tx.on('error', (err) => { + logger.error(`[Subscription] Error on ${id}: ${err.message}`); + stop(id, 'Subscription errored out (tx)'); + }); - const { url } = this.clients.get(id)!; + mergeTxRxStreams(id, rx, tx); - logger.info(`[Subscription] Trigger ${id}`); + console.log(rx._readableState); - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(result), - headers: { - 'Content-Type': 'application/json', - }, + clients.set(id, { + subscriptionName, + rx, + tx, }); - await response.text(); - } - private buildOperations() { - const subscription = this.sofa.schema.getSubscriptionType(); + return { id }; + }; + + const stop = async ( + /** + * Subscription ID + */ + id: ID, + /** + * Reason for termination. Set to null to skip sending termination message. + */ + terminationReason?: string | null + ) => { + logger.info(`[Subscription] Stop ${id}`); - if (!subscription) { - return; - } + const client = clients.get(id); - const fieldMap = subscription.getFields(); + if (!client) { + throw new Error( + `Subscription with ID '${id}' does not exist (${terminationReason})` + ); + } - for (const field in fieldMap) { - const operationNode = buildOperationNodeForField({ - kind: 'subscription' as OperationTypeNode, - field, - schema: this.sofa.schema, - models: this.sofa.models, - ignore: this.sofa.ignore, - circularReferenceDepth: this.sofa.depthLimit, - }); - const document: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [operationNode], + if (sofa.webhooks?.terminationMessage && terminationReason !== null) { + const termination = + typeof sofa.webhooks.terminationMessage === 'function' + ? sofa.webhooks.terminationMessage( + terminationReason || 'Subscription terminated' + ) + : { + reason: + typeof sofa.webhooks.terminationMessage === 'boolean' + ? terminationReason || 'Subscription terminated' + : sofa.webhooks.terminationMessage, + }; + + const terminationMessage: ExecutionResult< + ObjMap, + ObjMap + > = { + extensions: { + webhook: { + termination, + }, + }, }; + client.tx.write(terminationMessage); + } - const { variables, name: operationName } = getOperationInfo(document)!; + // stop listening for messages on the subscription + client.rx.destroy(); + // clear the sending stream since we are done + client.tx.end(); + // remove the client from the map + clients.delete(id); - this.operations.set(field, { - operationName, - document, - variables, - }); + return { id }; + }; + + const update = async ( + event: UpdateSubscriptionEvent, + contextValue: ContextValue + ) => { + logger.info(`[Subscription] Update ${event.id}`, event); + const client = clients.get(event.id); + if (!client) { + throw new Error(`Subscription with ID '${event.id}' does not exist`); } - } + + client.rx.destroy(); + + const rx = await readableStreamFromOperationCall( + event.id, + client.subscriptionName, + event, + contextValue + ); + + mergeTxRxStreams(event.id, rx, client.tx); + client.rx = rx; + return { id: event.id }; + }; + return { start, stop, update }; } diff --git a/src/subscriptions_old.ts b/src/subscriptions_old.ts new file mode 100644 index 00000000..5b8a2dfd --- /dev/null +++ b/src/subscriptions_old.ts @@ -0,0 +1,346 @@ +import { + type DocumentNode, + type VariableDefinitionNode, + type ExecutionResult, + Kind, + type OperationTypeNode, +} from 'graphql'; +import { fetch, crypto } from '@whatwg-node/fetch'; +import { buildOperationNodeForField } from '@graphql-tools/utils'; +import type { ContextValue } from './types.js'; +import type { Sofa } from './sofa.js'; +import { getOperationInfo } from './ast.js'; +import { parseVariable } from './parse.js'; +import { logger } from './logger.js'; +import { ObjMap } from 'graphql/jsutils/ObjMap.js'; + +function isAsyncIterable(obj: any): obj is AsyncIterable { + return typeof obj[Symbol.asyncIterator] === 'function'; +} + +// To start subscription: +// - an url that Sofa should trigger +// - name of a subscription +// - variables if needed +// - some sort of an auth token +// - Sofa should return a unique id of that subscription +// - respond with OK 200 + +// To stop subscription +// - an id is required +// - respond with OK 200 + +// To update subscription +// - an id is required +// - new set of variables + +export type ID = string; +export type SubscriptionFieldName = string; + +export interface StartSubscriptionEvent { + subscription: SubscriptionFieldName; + variables: any; + url: string; +} + +export interface UpdateSubscriptionEvent { + id: ID; + variables: any; +} + +export interface StopSubscriptionResponse { + id: ID; +} + +interface BuiltOperation { + operationName: string; + document: DocumentNode; + variables: ReadonlyArray; +} + +interface StoredClient { + name: SubscriptionFieldName; + url: string; + iterator: AsyncIterator; + maxLifetimeTimeout?: NodeJS.Timeout; +} + +export class SubscriptionManager { + private operations = new Map(); + private clients = new Map(); + + constructor(private sofa: Sofa) { + this.buildOperations(); + } + + public async start( + event: StartSubscriptionEvent, + contextValue: ContextValue + ) { + const id = crypto.randomUUID(); + const name = event.subscription; + + if (!this.operations.has(name)) { + throw new Error(`Subscription '${name}' is not available`); + } + + logger.info(`[Subscription] Start ${id}`, event); + + const result = await this.execute({ + id, + name, + url: event.url, + variables: event.variables, + contextValue, + }); + + if (typeof result !== 'undefined') { + return result; + } + + return { id }; + } + + public async stop( + /** + * Subscription ID + */ + id: ID, + /** + * Reason for termination. Set to null to skip sending termination message. + */ + terminationReason?: string | null + ): Promise { + logger.info(`[Subscription] Stop ${id}`); + + if (!this.clients.has(id)) { + throw new Error( + `Subscription with ID '${id}' does not exist (${terminationReason})` + ); + } + + const execution = this.clients.get(id)!; + + if (execution.maxLifetimeTimeout) { + clearTimeout(execution.maxLifetimeTimeout); + } + + if (this.sofa.webhooks?.terminationMessage && terminationReason !== null) { + const termination = + typeof this.sofa.webhooks.terminationMessage === 'function' + ? this.sofa.webhooks.terminationMessage( + terminationReason || 'Subscription terminated' + ) + : { + reason: + typeof this.sofa.webhooks.terminationMessage === 'boolean' + ? terminationReason || 'Subscription terminated' + : this.sofa.webhooks.terminationMessage, + }; + + const terminationMessage: ExecutionResult< + ObjMap, + ObjMap + > = { + extensions: { + webhook: { + termination, + }, + }, + }; + await this.sendData({ + id, + result: terminationMessage, + }); + } + + if (execution.iterator.return) { + execution.iterator.return(); + } + + this.clients.delete(id); + + return { id }; + } + + public async update( + event: UpdateSubscriptionEvent, + contextValue: ContextValue + ) { + const { variables, id } = event; + + logger.info(`[Subscription] Update ${id}`, event); + + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + + const { name: subscription, url } = this.clients.get(id)!; + + this.stop(id, null); + + return this.start( + { + url, + subscription, + variables, + }, + contextValue + ); + } + + private async execute({ + id, + name, + url, + variables, + contextValue, + }: { + id: ID; + name: SubscriptionFieldName; + url: string; + variables: Record; + contextValue: ContextValue; + }) { + const { + document, + operationName, + variables: variableNodes, + } = this.operations.get(name)!; + + const variableValues = variableNodes.reduce((values, variable) => { + const value = parseVariable({ + value: variables[variable.variable.name.value], + variable, + schema: this.sofa.schema, + }); + + if (typeof value === 'undefined') { + return values; + } + + return { + ...values, + [variable.variable.name.value]: value, + }; + }, {}); + + const execution = await this.sofa.subscribe({ + schema: this.sofa.schema, + document, + operationName, + variableValues, + contextValue, + }); + + if (isAsyncIterable(execution)) { + // successful + + const client: StoredClient = { + name, + url, + iterator: execution as any, + maxLifetimeTimeout: this.sofa.webhooks + ?.maxSubscriptionWebhookLifetimeSeconds + ? (() => { + logger.info( + `[Subscription] Setting max lifetime for subscription ${id} to ${this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds}s` + ); + return setTimeout(() => { + if (!this.clients.has(id)) { + return; + } + logger.info( + `[Subscription] Max lifetime reached for subscription ${id}, closing.` + ); + this.stop( + id, + `Max subscription lifetime reached (${this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds}s)` + ); + }, this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds * 1000); + })() + : undefined, + }; + + // add execution to clients + this.clients.set(id, client); + + // success + (async () => { + for await (const result of execution) { + await this.sendData({ + id, + result, + }); + } + })().then( + () => { + // completes + if (this.clients.has(id)) { + this.stop(id, 'Subscription completed, no further data available'); + } + }, + (e) => { + logger.error(e); + if (this.clients.has(id)) { + this.stop(id, 'Subscription errored out'); + } + } + ); + } else { + return execution as ExecutionResult; + } + } + + private async sendData({ id, result }: { id: ID; result: any }) { + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + + const { url } = this.clients.get(id)!; + + logger.info(`[Subscription] Trigger ${id}`); + + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(result), + headers: { + 'Content-Type': 'application/json', + }, + }); + await response.text(); + } + + private buildOperations() { + const subscription = this.sofa.schema.getSubscriptionType(); + + if (!subscription) { + return; + } + + const fieldMap = subscription.getFields(); + + for (const field in fieldMap) { + const operationNode = buildOperationNodeForField({ + kind: 'subscription' as OperationTypeNode, + field, + schema: this.sofa.schema, + models: this.sofa.models, + ignore: this.sofa.ignore, + circularReferenceDepth: this.sofa.depthLimit, + }); + const document: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [operationNode], + }; + + const { variables, name: operationName } = getOperationInfo(document)!; + + this.operations.set(field, { + operationName, + document, + variables, + }); + } + } +} diff --git a/tests/subscriptions.spec.ts b/tests/subscriptions.spec.ts index fd1e4377..c38e5195 100644 --- a/tests/subscriptions.spec.ts +++ b/tests/subscriptions.spec.ts @@ -3,7 +3,14 @@ jest.mock('@whatwg-node/fetch', () => { return { ...original, fetch: jest.fn().mockResolvedValue({ - text: () => ({}), + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + text: async () => '', + body: { + cancel: () => undefined, + }, }), }; }); @@ -44,7 +51,7 @@ const typeDefs = /* GraphQL */ ` } `; -test('should start subscriptions', async () => { +test.only('should start subscriptions', async () => { (fetch as jest.Mock).mockClear(); const pubsub = createPubSub(); const sofa = useSofa({ @@ -205,3 +212,53 @@ test('should start subscriptions with parameters', async () => { await delay(1000); expect(fetch).toHaveBeenCalledTimes(1); }); + +// test('should send termination message ', async () => { +// (fetch as jest.Mock).mockClear(); + +// const pubsub = createPubSub(); +// const sofa = useSofa({ +// basePath: '/api', +// schema: createSchema({ +// typeDefs, +// resolvers: { +// Subscription: { +// onBook: { +// subscribe: () => pubsub.subscribe(BOOK_ADDED), +// }, +// }, +// }, +// }), +// webhooks: { +// terminationMessage: true, +// }, +// }); + +// const res = await sofa.fetch('http://localhost:4000/api/webhook', { +// method: 'POST', +// body: JSON.stringify({ +// subscription: 'onBook', +// url: '/book', +// }), +// }); +// expect(res.status).toBe(200); +// const resBody = await res.json(); +// pubsub.publish(BOOK_ADDED, { onBook: testBook1 }); +// await delay(1000); +// expect(fetch).toHaveBeenCalledTimes(1); +// const deleteRes = await sofa.fetch( +// `http://localhost:4000/api/webhook/${resBody.id}`, +// { +// method: 'DELETE', +// } +// ); +// expect(deleteRes.status).toBe(200); +// pubsub.publish(BOOK_ADDED, { onBook: testBook2 }); +// await delay(1000); +// expect(fetch).toHaveBeenCalledTimes(2); +// expect(fetch).toHaveBeenLastCalledWith({ +// method: 'POST', +// body: '{"extensions":{"webhook":{"termination":{"reason":"Subscription terminated"}}}}', +// headers: { 'Content-Type': 'application/json' }, +// }); +// }); diff --git a/yarn.lock b/yarn.lock index 85be005d..da1da130 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2013,6 +2013,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/readable-stream@4.0.22": + version "4.0.22" + resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-4.0.22.tgz#882fda4f17b6580acb257df3f22ca69c05470b29" + integrity sha512-/FFhJpfCLAPwAcN3mFycNUa77ddnr8jTgF5VmSNetaemWB2cIlfCA9t0YTM3JAT0wOcv8D4tjPo7pkDhK3EJIg== + dependencies: + "@types/node" "*" + "@types/send@*": version "0.17.5" resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74" @@ -2218,6 +2225,13 @@ "@whatwg-node/promise-helpers" "^1.3.2" tslib "^2.6.3" +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn@^8.14.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" @@ -2390,6 +2404,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + better-path-resolve@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/better-path-resolve/-/better-path-resolve-1.0.0.tgz#13a35a1104cdd48a7b74bf8758f96a1ee613f99d" @@ -2465,6 +2484,14 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" @@ -2847,11 +2874,21 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-7.1.1.tgz#3eb3c83d239488e7b409d48e8813b76bb55c9c43" @@ -3247,6 +3284,11 @@ iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.2.0, ignore@^5.2.4: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -4361,6 +4403,11 @@ pretty-format@30.0.5, pretty-format@^30.0.0: ansi-styles "^5.2.0" react-is "^18.3.1" +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -4403,6 +4450,17 @@ read-yaml-file@^1.1.0: pify "^4.0.1" strip-bom "^3.0.0" +readable-stream@4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" + integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -4529,6 +4587,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -4721,6 +4784,13 @@ string-width@^7.0.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" +string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" From 7ddacc438791c067397839f0cd22729e42ec1dc5 Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Sat, 25 Oct 2025 16:14:40 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat:=20fully=20implement=20sub?= =?UTF-8?q?=20rework=20with=20max=20lifetime=20and=20termination=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 8 +- src/sofa.ts | 5 - src/subscriptions.ts | 56 ++- src/subscriptions_old.ts | 346 ------------------ tests/subscriptions.spec.ts | 140 +++++--- yarn.lock | 699 +++++++++++++++++++++--------------- 6 files changed, 524 insertions(+), 730 deletions(-) delete mode 100644 src/subscriptions_old.ts diff --git a/package.json b/package.json index b4d78f2f..a5f41a9e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "clean": "rm -rf dist", "prebuild": "yarn clean", "build": "bob build --single", - "test": "jest --no-watchman --detectOpenHandles --detectLeaks", + "test": "jest --no-watchman --detectOpenHandles --logHeapUsage", "release": "yarn build && changeset publish" }, "devDependencies": { @@ -83,18 +83,18 @@ "@types/readable-stream": "4.0.22", "@types/swagger-ui-dist": "3.30.6", "@types/yamljs": "0.2.34", - "babel-jest": "30.0.5", + "babel-jest": "30.2.0", "bob-the-bundler": "7.0.1", "chalk": "^5.4.1", "graphql": "16.11.0", "graphql-scalars": "1.24.2", "graphql-yoga": "5.15.1", "husky": "9.1.7", - "jest": "30.0.5", + "jest": "30.2.0", "lint-staged": "16.1.4", "prettier": "3.6.2", "swagger-ui-dist": "5.27.1", - "ts-jest": "29.4.1", + "ts-jest": "29.4.5", "tsup": "8.5.0", "typescript": "5.9.2" }, diff --git a/src/sofa.ts b/src/sofa.ts index cde105fc..ade984db 100644 --- a/src/sofa.ts +++ b/src/sofa.ts @@ -99,11 +99,6 @@ export interface SofaConfig { | boolean | string | ((reason: string) => { reason: string }); - /** - * Timeout for webhook requests in seconds. - * Default is 5 seconds. - */ - timeoutSeconds?: number; }; } diff --git a/src/subscriptions.ts b/src/subscriptions.ts index 0ab3493f..b588c45a 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -13,7 +13,7 @@ import { getOperationInfo } from './ast.js'; import { parseVariable } from './parse.js'; import { logger } from './logger.js'; import { ObjMap } from 'graphql/jsutils/ObjMap.js'; -import { pipeline, Readable, Writable } from 'readable-stream'; +import { pipeline, Writable } from 'readable-stream'; type SubscriptionFieldName = string; type ID = string; @@ -37,8 +37,9 @@ interface BuiltOperation { interface StoredClient { subscriptionName: SubscriptionFieldName; - rx: Readable; + rx: AsyncIterator; tx: Writable; + timeoutHandle?: NodeJS.Timeout; } function isAsyncIterable(obj: any): obj is AsyncIterable { @@ -121,34 +122,20 @@ export function createSubscriptionManager(sofa: Sofa) { throw subscriptionIterable as ExecutionResult; } - // In case we do not get yielded an actual readable stream - // we want to convert it to one - return Readable.from(subscriptionIterable, { - objectMode: true, - highWaterMark: 100, - }); + return subscriptionIterable; }; - const mergeTxRxStreams = (id: ID, rx: Readable, tx: Writable) => { + const mergeTxRxStreams = (id: ID, rx: AsyncIterable, tx: Writable) => { pipeline(rx, tx, (err) => { if (err) { logger.error(`[Subscription] Pipeline error on ${id}: ${err.message}`); stop(id, 'Subscription pipeline errored out'); } - }); - - rx.on('data', (chunk) => { - console.log('rx data', chunk); - }); - rx.on('close', () => { - stop(id, 'Subscription closed by client'); - }); - rx.on('end', () => { - stop(id, 'Subscription completed, no further data available'); - }); - rx.on('error', (err) => { - logger.error(`[Subscription] Error on ${id}: ${err.message}`); - stop(id, 'Subscription errored out (rx)'); + // in case the rx is done without the stop function being called (and therefore performing a deletion of the client), + // we want to make sure we clean up the client entry properly + if (clients.has(id)) { + stop(id, 'Subscription finished, no further data available'); + } }); }; @@ -179,9 +166,6 @@ export function createSubscriptionManager(sofa: Sofa) { headers: { 'Content-Type': 'application/json', }, - signal: AbortSignal.timeout( - (sofa.webhooks?.timeoutSeconds || 5) * 1000 - ), }); if (!response.ok) { @@ -211,12 +195,16 @@ export function createSubscriptionManager(sofa: Sofa) { mergeTxRxStreams(id, rx, tx); - console.log(rx._readableState); clients.set(id, { subscriptionName, rx, tx, + timeoutHandle: sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds + ? setTimeout(() => { + stop(id, 'Max subscription lifetime reached'); + }, sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds * 1000) + : undefined, }); return { id }; @@ -268,8 +256,14 @@ export function createSubscriptionManager(sofa: Sofa) { client.tx.write(terminationMessage); } - // stop listening for messages on the subscription - client.rx.destroy(); + if (client.timeoutHandle) { + clearTimeout(client.timeoutHandle); + } + + // this terminates the rx stream + if (client.rx.return) { + await client.rx.return(); + } // clear the sending stream since we are done client.tx.end(); // remove the client from the map @@ -288,7 +282,9 @@ export function createSubscriptionManager(sofa: Sofa) { throw new Error(`Subscription with ID '${event.id}' does not exist`); } - client.rx.destroy(); + if (client.rx.return) { + await client.rx.return(); + } const rx = await readableStreamFromOperationCall( event.id, diff --git a/src/subscriptions_old.ts b/src/subscriptions_old.ts deleted file mode 100644 index 5b8a2dfd..00000000 --- a/src/subscriptions_old.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { - type DocumentNode, - type VariableDefinitionNode, - type ExecutionResult, - Kind, - type OperationTypeNode, -} from 'graphql'; -import { fetch, crypto } from '@whatwg-node/fetch'; -import { buildOperationNodeForField } from '@graphql-tools/utils'; -import type { ContextValue } from './types.js'; -import type { Sofa } from './sofa.js'; -import { getOperationInfo } from './ast.js'; -import { parseVariable } from './parse.js'; -import { logger } from './logger.js'; -import { ObjMap } from 'graphql/jsutils/ObjMap.js'; - -function isAsyncIterable(obj: any): obj is AsyncIterable { - return typeof obj[Symbol.asyncIterator] === 'function'; -} - -// To start subscription: -// - an url that Sofa should trigger -// - name of a subscription -// - variables if needed -// - some sort of an auth token -// - Sofa should return a unique id of that subscription -// - respond with OK 200 - -// To stop subscription -// - an id is required -// - respond with OK 200 - -// To update subscription -// - an id is required -// - new set of variables - -export type ID = string; -export type SubscriptionFieldName = string; - -export interface StartSubscriptionEvent { - subscription: SubscriptionFieldName; - variables: any; - url: string; -} - -export interface UpdateSubscriptionEvent { - id: ID; - variables: any; -} - -export interface StopSubscriptionResponse { - id: ID; -} - -interface BuiltOperation { - operationName: string; - document: DocumentNode; - variables: ReadonlyArray; -} - -interface StoredClient { - name: SubscriptionFieldName; - url: string; - iterator: AsyncIterator; - maxLifetimeTimeout?: NodeJS.Timeout; -} - -export class SubscriptionManager { - private operations = new Map(); - private clients = new Map(); - - constructor(private sofa: Sofa) { - this.buildOperations(); - } - - public async start( - event: StartSubscriptionEvent, - contextValue: ContextValue - ) { - const id = crypto.randomUUID(); - const name = event.subscription; - - if (!this.operations.has(name)) { - throw new Error(`Subscription '${name}' is not available`); - } - - logger.info(`[Subscription] Start ${id}`, event); - - const result = await this.execute({ - id, - name, - url: event.url, - variables: event.variables, - contextValue, - }); - - if (typeof result !== 'undefined') { - return result; - } - - return { id }; - } - - public async stop( - /** - * Subscription ID - */ - id: ID, - /** - * Reason for termination. Set to null to skip sending termination message. - */ - terminationReason?: string | null - ): Promise { - logger.info(`[Subscription] Stop ${id}`); - - if (!this.clients.has(id)) { - throw new Error( - `Subscription with ID '${id}' does not exist (${terminationReason})` - ); - } - - const execution = this.clients.get(id)!; - - if (execution.maxLifetimeTimeout) { - clearTimeout(execution.maxLifetimeTimeout); - } - - if (this.sofa.webhooks?.terminationMessage && terminationReason !== null) { - const termination = - typeof this.sofa.webhooks.terminationMessage === 'function' - ? this.sofa.webhooks.terminationMessage( - terminationReason || 'Subscription terminated' - ) - : { - reason: - typeof this.sofa.webhooks.terminationMessage === 'boolean' - ? terminationReason || 'Subscription terminated' - : this.sofa.webhooks.terminationMessage, - }; - - const terminationMessage: ExecutionResult< - ObjMap, - ObjMap - > = { - extensions: { - webhook: { - termination, - }, - }, - }; - await this.sendData({ - id, - result: terminationMessage, - }); - } - - if (execution.iterator.return) { - execution.iterator.return(); - } - - this.clients.delete(id); - - return { id }; - } - - public async update( - event: UpdateSubscriptionEvent, - contextValue: ContextValue - ) { - const { variables, id } = event; - - logger.info(`[Subscription] Update ${id}`, event); - - if (!this.clients.has(id)) { - throw new Error(`Subscription with ID '${id}' does not exist`); - } - - const { name: subscription, url } = this.clients.get(id)!; - - this.stop(id, null); - - return this.start( - { - url, - subscription, - variables, - }, - contextValue - ); - } - - private async execute({ - id, - name, - url, - variables, - contextValue, - }: { - id: ID; - name: SubscriptionFieldName; - url: string; - variables: Record; - contextValue: ContextValue; - }) { - const { - document, - operationName, - variables: variableNodes, - } = this.operations.get(name)!; - - const variableValues = variableNodes.reduce((values, variable) => { - const value = parseVariable({ - value: variables[variable.variable.name.value], - variable, - schema: this.sofa.schema, - }); - - if (typeof value === 'undefined') { - return values; - } - - return { - ...values, - [variable.variable.name.value]: value, - }; - }, {}); - - const execution = await this.sofa.subscribe({ - schema: this.sofa.schema, - document, - operationName, - variableValues, - contextValue, - }); - - if (isAsyncIterable(execution)) { - // successful - - const client: StoredClient = { - name, - url, - iterator: execution as any, - maxLifetimeTimeout: this.sofa.webhooks - ?.maxSubscriptionWebhookLifetimeSeconds - ? (() => { - logger.info( - `[Subscription] Setting max lifetime for subscription ${id} to ${this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds}s` - ); - return setTimeout(() => { - if (!this.clients.has(id)) { - return; - } - logger.info( - `[Subscription] Max lifetime reached for subscription ${id}, closing.` - ); - this.stop( - id, - `Max subscription lifetime reached (${this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds}s)` - ); - }, this.sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds * 1000); - })() - : undefined, - }; - - // add execution to clients - this.clients.set(id, client); - - // success - (async () => { - for await (const result of execution) { - await this.sendData({ - id, - result, - }); - } - })().then( - () => { - // completes - if (this.clients.has(id)) { - this.stop(id, 'Subscription completed, no further data available'); - } - }, - (e) => { - logger.error(e); - if (this.clients.has(id)) { - this.stop(id, 'Subscription errored out'); - } - } - ); - } else { - return execution as ExecutionResult; - } - } - - private async sendData({ id, result }: { id: ID; result: any }) { - if (!this.clients.has(id)) { - throw new Error(`Subscription with ID '${id}' does not exist`); - } - - const { url } = this.clients.get(id)!; - - logger.info(`[Subscription] Trigger ${id}`); - - const response = await fetch(url, { - method: 'POST', - body: JSON.stringify(result), - headers: { - 'Content-Type': 'application/json', - }, - }); - await response.text(); - } - - private buildOperations() { - const subscription = this.sofa.schema.getSubscriptionType(); - - if (!subscription) { - return; - } - - const fieldMap = subscription.getFields(); - - for (const field in fieldMap) { - const operationNode = buildOperationNodeForField({ - kind: 'subscription' as OperationTypeNode, - field, - schema: this.sofa.schema, - models: this.sofa.models, - ignore: this.sofa.ignore, - circularReferenceDepth: this.sofa.depthLimit, - }); - const document: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [operationNode], - }; - - const { variables, name: operationName } = getOperationInfo(document)!; - - this.operations.set(field, { - operationName, - document, - variables, - }); - } - } -} diff --git a/tests/subscriptions.spec.ts b/tests/subscriptions.spec.ts index c38e5195..50342e92 100644 --- a/tests/subscriptions.spec.ts +++ b/tests/subscriptions.spec.ts @@ -51,7 +51,7 @@ const typeDefs = /* GraphQL */ ` } `; -test.only('should start subscriptions', async () => { +test('should start subscriptions', async () => { (fetch as jest.Mock).mockClear(); const pubsub = createPubSub(); const sofa = useSofa({ @@ -213,52 +213,96 @@ test('should start subscriptions with parameters', async () => { expect(fetch).toHaveBeenCalledTimes(1); }); -// test('should send termination message ', async () => { -// (fetch as jest.Mock).mockClear(); +test('should send termination message ', async () => { + (fetch as jest.Mock).mockClear(); -// const pubsub = createPubSub(); -// const sofa = useSofa({ -// basePath: '/api', -// schema: createSchema({ -// typeDefs, -// resolvers: { -// Subscription: { -// onBook: { -// subscribe: () => pubsub.subscribe(BOOK_ADDED), -// }, -// }, -// }, -// }), -// webhooks: { -// terminationMessage: true, -// }, -// }); + const pubsub = createPubSub(); + const sofa = useSofa({ + basePath: '/api', + schema: createSchema({ + typeDefs, + resolvers: { + Subscription: { + onBook: { + subscribe: () => pubsub.subscribe(BOOK_ADDED), + }, + }, + }, + }), + webhooks: { + terminationMessage: true, + maxSubscriptionWebhookLifetimeSeconds: 60, + }, + }); -// const res = await sofa.fetch('http://localhost:4000/api/webhook', { -// method: 'POST', -// body: JSON.stringify({ -// subscription: 'onBook', -// url: '/book', -// }), -// }); -// expect(res.status).toBe(200); -// const resBody = await res.json(); -// pubsub.publish(BOOK_ADDED, { onBook: testBook1 }); -// await delay(1000); -// expect(fetch).toHaveBeenCalledTimes(1); -// const deleteRes = await sofa.fetch( -// `http://localhost:4000/api/webhook/${resBody.id}`, -// { -// method: 'DELETE', -// } -// ); -// expect(deleteRes.status).toBe(200); -// pubsub.publish(BOOK_ADDED, { onBook: testBook2 }); -// await delay(1000); -// expect(fetch).toHaveBeenCalledTimes(2); -// expect(fetch).toHaveBeenLastCalledWith({ -// method: 'POST', -// body: '{"extensions":{"webhook":{"termination":{"reason":"Subscription terminated"}}}}', -// headers: { 'Content-Type': 'application/json' }, -// }); -// }); + const res = await sofa.fetch('http://localhost:4000/api/webhook', { + method: 'POST', + body: JSON.stringify({ + subscription: 'onBook', + url: '/book', + }), + }); + expect(res.status).toBe(200); + const resBody = await res.json(); + pubsub.publish(BOOK_ADDED, { onBook: testBook1 }); + await delay(1000); + expect(fetch).toHaveBeenCalledTimes(1); + const deleteRes = await sofa.fetch( + `http://localhost:4000/api/webhook/${resBody.id}`, + { + method: 'DELETE', + } + ); + expect(deleteRes.status).toBe(200); + pubsub.publish(BOOK_ADDED, { onBook: testBook2 }); + await delay(1000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenLastCalledWith('/book', { + method: 'POST', + body: '{"extensions":{"webhook":{"termination":{"reason":"Subscription terminated"}}}}', + headers: { 'Content-Type': 'application/json' }, + }); +}); + +test('should send termination message ', async () => { + (fetch as jest.Mock).mockClear(); + + const pubsub = createPubSub(); + const sofa = useSofa({ + basePath: '/api', + schema: createSchema({ + typeDefs, + resolvers: { + Subscription: { + onBook: { + subscribe: () => pubsub.subscribe(BOOK_ADDED), + }, + }, + }, + }), + webhooks: { + terminationMessage: true, + maxSubscriptionWebhookLifetimeSeconds: 2, + }, + }); + + const res = await sofa.fetch('http://localhost:4000/api/webhook', { + method: 'POST', + body: JSON.stringify({ + subscription: 'onBook', + url: '/book', + }), + }); + expect(res.status).toBe(200); + const resBody = await res.json(); + pubsub.publish(BOOK_ADDED, { onBook: testBook1 }); + await delay(1000); + expect(fetch).toHaveBeenCalledTimes(1); + await delay(2000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenLastCalledWith('/book', { + method: 'POST', + body: '{"extensions":{"webhook":{"termination":{"reason":"Max subscription lifetime reached"}}}}', + headers: { 'Content-Type': 'application/json' }, + }); +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index da1da130..5a9a5e6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1438,50 +1438,50 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.0.5.tgz#d7d027c2db5c64c20a973b7f3e57b49956d6c335" - integrity sha512-xY6b0XiL0Nav3ReresUarwl2oIz1gTnxGbGpho9/rbUWsLH0f1OD/VT84xs8c7VmH7MChnLb0pag6PhZhAdDiA== +"@jest/console@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.2.0.tgz#c52fcd5b58fdd2e8eb66b2fd8ae56f2f64d05b28" + integrity sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ== dependencies: - "@jest/types" "30.0.5" + "@jest/types" "30.2.0" "@types/node" "*" chalk "^4.1.2" - jest-message-util "30.0.5" - jest-util "30.0.5" + jest-message-util "30.2.0" + jest-util "30.2.0" slash "^3.0.0" -"@jest/core@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.0.5.tgz#b5778922d2928f676636e3ec199829554e61e452" - integrity sha512-fKD0OulvRsXF1hmaFgHhVJzczWzA1RXMMo9LTPuFXo9q/alDbME3JIyWYqovWsUBWSoBcsHaGPSLF9rz4l9Qeg== +"@jest/core@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.2.0.tgz#813d59faa5abd5510964a8b3a7b17cc77b775275" + integrity sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ== dependencies: - "@jest/console" "30.0.5" + "@jest/console" "30.2.0" "@jest/pattern" "30.0.1" - "@jest/reporters" "30.0.5" - "@jest/test-result" "30.0.5" - "@jest/transform" "30.0.5" - "@jest/types" "30.0.5" + "@jest/reporters" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" ansi-escapes "^4.3.2" chalk "^4.1.2" ci-info "^4.2.0" exit-x "^0.2.2" graceful-fs "^4.2.11" - jest-changed-files "30.0.5" - jest-config "30.0.5" - jest-haste-map "30.0.5" - jest-message-util "30.0.5" + jest-changed-files "30.2.0" + jest-config "30.2.0" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" jest-regex-util "30.0.1" - jest-resolve "30.0.5" - jest-resolve-dependencies "30.0.5" - jest-runner "30.0.5" - jest-runtime "30.0.5" - jest-snapshot "30.0.5" - jest-util "30.0.5" - jest-validate "30.0.5" - jest-watcher "30.0.5" + jest-resolve "30.2.0" + jest-resolve-dependencies "30.2.0" + jest-runner "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + jest-watcher "30.2.0" micromatch "^4.0.8" - pretty-format "30.0.5" + pretty-format "30.2.0" slash "^3.0.0" "@jest/diff-sequences@30.0.1": @@ -1489,15 +1489,15 @@ resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== -"@jest/environment@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.0.5.tgz#eaaae0403c7d3f8414053c2224acc3011e1c3a1b" - integrity sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA== +"@jest/environment@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.2.0.tgz#1e673cdb8b93ded707cf6631b8353011460831fa" + integrity sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g== dependencies: - "@jest/fake-timers" "30.0.5" - "@jest/types" "30.0.5" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - jest-mock "30.0.5" + jest-mock "30.2.0" "@jest/expect-utils@30.0.5": version "30.0.5" @@ -1506,40 +1506,52 @@ dependencies: "@jest/get-type" "30.0.1" -"@jest/expect@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.0.5.tgz#2bbd101df4869f5d171c3cfee881f810f1525005" - integrity sha512-6udac8KKrtTtC+AXZ2iUN/R7dp7Ydry+Fo6FPFnDG54wjVMnb6vW/XNlf7Xj8UDjAE3aAVAsR4KFyKk3TCXmTA== +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== dependencies: - expect "30.0.5" - jest-snapshot "30.0.5" + "@jest/get-type" "30.1.0" -"@jest/fake-timers@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.0.5.tgz#c028a9465a44b7744cb2368196bed89ce13c7054" - integrity sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw== +"@jest/expect@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.2.0.tgz#9a5968499bb8add2bbb09136f69f7df5ddbf3185" + integrity sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA== dependencies: - "@jest/types" "30.0.5" + expect "30.2.0" + jest-snapshot "30.2.0" + +"@jest/fake-timers@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.2.0.tgz#0941ddc28a339b9819542495b5408622dc9e94ec" + integrity sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw== + dependencies: + "@jest/types" "30.2.0" "@sinonjs/fake-timers" "^13.0.0" "@types/node" "*" - jest-message-util "30.0.5" - jest-mock "30.0.5" - jest-util "30.0.5" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" "@jest/get-type@30.0.1": version "30.0.1" resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.0.1.tgz#0d32f1bbfba511948ad247ab01b9007724fc9f52" integrity sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw== -"@jest/globals@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.0.5.tgz#ca70e0ac08ab40417cf8cd92bcb76116c2ccca63" - integrity sha512-7oEJT19WW4oe6HR7oLRvHxwlJk2gev0U9px3ufs8sX9PoD1Eza68KF0/tlN7X0dq/WVsBScXQGgCldA1V9Y/jA== +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.2.0.tgz#2f4b696d5862664b89c4ee2e49ae24d2bb7e0988" + integrity sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw== dependencies: - "@jest/environment" "30.0.5" - "@jest/expect" "30.0.5" - "@jest/types" "30.0.5" - jest-mock "30.0.5" + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/types" "30.2.0" + jest-mock "30.2.0" "@jest/pattern@30.0.1": version "30.0.1" @@ -1549,16 +1561,16 @@ "@types/node" "*" jest-regex-util "30.0.1" -"@jest/reporters@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.0.5.tgz#b83585e6448d390a8d92a641c567f1655976d5c6" - integrity sha512-mafft7VBX4jzED1FwGC1o/9QUM2xebzavImZMeqnsklgcyxBto8mV4HzNSzUrryJ+8R9MFOM3HgYuDradWR+4g== +"@jest/reporters@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.2.0.tgz#a36b28fcbaf0c4595250b108e6f20e363348fd91" + integrity sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "30.0.5" - "@jest/test-result" "30.0.5" - "@jest/transform" "30.0.5" - "@jest/types" "30.0.5" + "@jest/console" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@jridgewell/trace-mapping" "^0.3.25" "@types/node" "*" chalk "^4.1.2" @@ -1571,9 +1583,9 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^5.0.0" istanbul-reports "^3.1.3" - jest-message-util "30.0.5" - jest-util "30.0.5" - jest-worker "30.0.5" + jest-message-util "30.2.0" + jest-util "30.2.0" + jest-worker "30.2.0" slash "^3.0.0" string-length "^4.0.2" v8-to-istanbul "^9.0.1" @@ -1585,12 +1597,12 @@ dependencies: "@sinclair/typebox" "^0.34.0" -"@jest/snapshot-utils@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.0.5.tgz#e23a0e786f174e8cff7f150c1cfbdc9cb7cc81a4" - integrity sha512-XcCQ5qWHLvi29UUrowgDFvV4t7ETxX91CbDczMnoqXPOIcZOxyNdSjm6kV5XMc8+HkxfRegU/MUmnTbJRzGrUQ== +"@jest/snapshot-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz#387858eb90c2f98f67bff327435a532ac5309fbe" + integrity sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug== dependencies: - "@jest/types" "30.0.5" + "@jest/types" "30.2.0" chalk "^4.1.2" graceful-fs "^4.2.11" natural-compare "^1.4.0" @@ -1604,42 +1616,42 @@ callsites "^3.1.0" graceful-fs "^4.2.11" -"@jest/test-result@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.0.5.tgz#064c5210c24d5ea192fb02ceddad3be1cfa557c8" - integrity sha512-wPyztnK0gbDMQAJZ43tdMro+qblDHH1Ru/ylzUo21TBKqt88ZqnKKK2m30LKmLLoKtR2lxdpCC/P3g1vfKcawQ== +"@jest/test-result@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.2.0.tgz#9c0124377fb7996cdffb86eda3dbc56eacab363d" + integrity sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg== dependencies: - "@jest/console" "30.0.5" - "@jest/types" "30.0.5" + "@jest/console" "30.2.0" + "@jest/types" "30.2.0" "@types/istanbul-lib-coverage" "^2.0.6" collect-v8-coverage "^1.0.2" -"@jest/test-sequencer@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.0.5.tgz#c6dba8fc3c386dd793c087626e8508ff1ead19f4" - integrity sha512-Aea/G1egWoIIozmDD7PBXUOxkekXl7ueGzrsGGi1SbeKgQqCYCIf+wfbflEbf2LiPxL8j2JZGLyrzZagjvW4YQ== +"@jest/test-sequencer@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz#bf0066bc72e176d58f5dfa7f212b6e7eee44f221" + integrity sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q== dependencies: - "@jest/test-result" "30.0.5" + "@jest/test-result" "30.2.0" graceful-fs "^4.2.11" - jest-haste-map "30.0.5" + jest-haste-map "30.2.0" slash "^3.0.0" -"@jest/transform@30.0.5": - version "30.0.5" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.0.5.tgz#f8ca2e9f7466b77b406807d3bef1f6790dd384e4" - integrity sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg== +"@jest/transform@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.2.0.tgz#54bef1a4510dcbd58d5d4de4fe2980a63077ef2a" + integrity sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA== dependencies: "@babel/core" "^7.27.4" - "@jest/types" "30.0.5" + "@jest/types" "30.2.0" "@jridgewell/trace-mapping" "^0.3.25" - babel-plugin-istanbul "^7.0.0" + babel-plugin-istanbul "^7.0.1" chalk "^4.1.2" convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" graceful-fs "^4.2.11" - jest-haste-map "30.0.5" + jest-haste-map "30.2.0" jest-regex-util "30.0.1" - jest-util "30.0.5" + jest-util "30.2.0" micromatch "^4.0.8" pirates "^4.0.7" slash "^3.0.0" @@ -1658,6 +1670,19 @@ "@types/yargs" "^17.0.33" chalk "^4.1.2" +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": version "0.3.12" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz#2234ce26c62889f03db3d7fea43c1932ab3e927b" @@ -2313,23 +2338,23 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -babel-jest@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.0.5.tgz#7cc7dd03d0d613125d458521f635b8c2361e89cc" - integrity sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg== +babel-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" + integrity sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw== dependencies: - "@jest/transform" "30.0.5" + "@jest/transform" "30.2.0" "@types/babel__core" "^7.20.5" - babel-plugin-istanbul "^7.0.0" - babel-preset-jest "30.0.1" + babel-plugin-istanbul "^7.0.1" + babel-preset-jest "30.2.0" chalk "^4.1.2" graceful-fs "^4.2.11" slash "^3.0.0" -babel-plugin-istanbul@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz#629a178f63b83dc9ecee46fd20266283b1f11280" - integrity sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw== +babel-plugin-istanbul@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz#d8b518c8ea199364cf84ccc82de89740236daf92" + integrity sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@istanbuljs/load-nyc-config" "^1.0.0" @@ -2337,13 +2362,11 @@ babel-plugin-istanbul@^7.0.0: istanbul-lib-instrument "^6.0.2" test-exclude "^6.0.0" -babel-plugin-jest-hoist@30.0.1: - version "30.0.1" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz#f271b2066d2c1fb26a863adb8e13f85b06247125" - integrity sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ== +babel-plugin-jest-hoist@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz#94c250d36b43f95900f3a219241e0f4648191ce2" + integrity sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA== dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" "@types/babel__core" "^7.20.5" babel-plugin-polyfill-corejs2@^0.4.14: @@ -2370,10 +2393,10 @@ babel-plugin-polyfill-regenerator@^0.6.5: dependencies: "@babel/helper-define-polyfill-provider" "^0.6.5" -babel-preset-current-node-syntax@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz#9a929eafece419612ef4ae4f60b1862ebad8ef30" - integrity sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw== +babel-preset-current-node-syntax@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" @@ -2391,13 +2414,13 @@ babel-preset-current-node-syntax@^1.1.0: "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-jest@30.0.1: - version "30.0.1" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz#7d28db9531bce264e846c8483d54236244b8ae88" - integrity sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw== +babel-preset-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz#04717843e561347781d6d7f69c81e6bcc3ed11ce" + integrity sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ== dependencies: - babel-plugin-jest-hoist "30.0.1" - babel-preset-current-node-syntax "^1.1.0" + babel-plugin-jest-hoist "30.2.0" + babel-preset-current-node-syntax "^1.2.0" balanced-match@^1.0.0: version "1.0.2" @@ -2924,7 +2947,19 @@ exit-x@^0.2.2: resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@30.0.5, expect@^30.0.0: +expect@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== + dependencies: + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +expect@^30.0.0: version "30.0.5" resolved "https://registry.yarnpkg.com/expect/-/expect-30.0.5.tgz#c23bf193c5e422a742bfd2990ad990811de41a5a" integrity sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ== @@ -3449,84 +3484,84 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -jest-changed-files@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.0.5.tgz#ec448f83bd9caa894dd7da8707f207c356a19924" - integrity sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A== +jest-changed-files@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c" + integrity sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ== dependencies: execa "^5.1.1" - jest-util "30.0.5" + jest-util "30.2.0" p-limit "^3.1.0" -jest-circus@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.0.5.tgz#9b4d44feb56c7ffe14411ad7fc08af188c5d4da7" - integrity sha512-h/sjXEs4GS+NFFfqBDYT7y5Msfxh04EwWLhQi0F8kuWpe+J/7tICSlswU8qvBqumR3kFgHbfu7vU6qruWWBPug== +jest-circus@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.2.0.tgz#98b8198b958748a2f322354311023d1d02e7603f" + integrity sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg== dependencies: - "@jest/environment" "30.0.5" - "@jest/expect" "30.0.5" - "@jest/test-result" "30.0.5" - "@jest/types" "30.0.5" + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" chalk "^4.1.2" co "^4.6.0" dedent "^1.6.0" is-generator-fn "^2.1.0" - jest-each "30.0.5" - jest-matcher-utils "30.0.5" - jest-message-util "30.0.5" - jest-runtime "30.0.5" - jest-snapshot "30.0.5" - jest-util "30.0.5" + jest-each "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" p-limit "^3.1.0" - pretty-format "30.0.5" + pretty-format "30.2.0" pure-rand "^7.0.0" slash "^3.0.0" stack-utils "^2.0.6" -jest-cli@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.0.5.tgz#c3fbfdabd1a5c428429476f915a1ba6d0774cc50" - integrity sha512-Sa45PGMkBZzF94HMrlX4kUyPOwUpdZasaliKN3mifvDmkhLYqLLg8HQTzn6gq7vJGahFYMQjXgyJWfYImKZzOw== +jest-cli@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.2.0.tgz#1780f8e9d66bf84a10b369aea60aeda7697dcc67" + integrity sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA== dependencies: - "@jest/core" "30.0.5" - "@jest/test-result" "30.0.5" - "@jest/types" "30.0.5" + "@jest/core" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" chalk "^4.1.2" exit-x "^0.2.2" import-local "^3.2.0" - jest-config "30.0.5" - jest-util "30.0.5" - jest-validate "30.0.5" + jest-config "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" yargs "^17.7.2" -jest-config@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.0.5.tgz#567cf39b595229b786506a496c22e222d5e8d480" - integrity sha512-aIVh+JNOOpzUgzUnPn5FLtyVnqc3TQHVMupYtyeURSb//iLColiMIR8TxCIDKyx9ZgjKnXGucuW68hCxgbrwmA== +jest-config@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.2.0.tgz#29df8c50e2ad801cc59c406b50176c18c362a90b" + integrity sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA== dependencies: "@babel/core" "^7.27.4" - "@jest/get-type" "30.0.1" + "@jest/get-type" "30.1.0" "@jest/pattern" "30.0.1" - "@jest/test-sequencer" "30.0.5" - "@jest/types" "30.0.5" - babel-jest "30.0.5" + "@jest/test-sequencer" "30.2.0" + "@jest/types" "30.2.0" + babel-jest "30.2.0" chalk "^4.1.2" ci-info "^4.2.0" deepmerge "^4.3.1" glob "^10.3.10" graceful-fs "^4.2.11" - jest-circus "30.0.5" - jest-docblock "30.0.1" - jest-environment-node "30.0.5" + jest-circus "30.2.0" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" jest-regex-util "30.0.1" - jest-resolve "30.0.5" - jest-runner "30.0.5" - jest-util "30.0.5" - jest-validate "30.0.5" + jest-resolve "30.2.0" + jest-runner "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" micromatch "^4.0.8" parse-json "^5.2.0" - pretty-format "30.0.5" + pretty-format "30.2.0" slash "^3.0.0" strip-json-comments "^3.1.1" @@ -3540,62 +3575,72 @@ jest-diff@30.0.5: chalk "^4.1.2" pretty-format "30.0.5" -jest-docblock@30.0.1: - version "30.0.1" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.0.1.tgz#545ff59f2fa88996bd470dba7d3798a8421180b1" - integrity sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA== +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" + +jest-docblock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.2.0.tgz#42cd98d69f887e531c7352309542b1ce4ee10256" + integrity sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA== dependencies: detect-newline "^3.1.0" -jest-each@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.0.5.tgz#5962264ff246cd757ba44db096c1bc5b4835173e" - integrity sha512-dKjRsx1uZ96TVyejD3/aAWcNKy6ajMaN531CwWIsrazIqIoXI9TnnpPlkrEYku/8rkS3dh2rbH+kMOyiEIv0xQ== +jest-each@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.2.0.tgz#39e623ae71641c2ac3ee69b3ba3d258fce8e768d" + integrity sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ== dependencies: - "@jest/get-type" "30.0.1" - "@jest/types" "30.0.5" + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" chalk "^4.1.2" - jest-util "30.0.5" - pretty-format "30.0.5" + jest-util "30.2.0" + pretty-format "30.2.0" -jest-environment-node@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.0.5.tgz#6a98dd80e0384ead67ed05643381395f6cda93c9" - integrity sha512-ppYizXdLMSvciGsRsMEnv/5EFpvOdXBaXRBzFUDPWrsfmog4kYrOGWXarLllz6AXan6ZAA/kYokgDWuos1IKDA== +jest-environment-node@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.2.0.tgz#3def7980ebd2fd86e74efd4d2e681f55ab38da0f" + integrity sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA== dependencies: - "@jest/environment" "30.0.5" - "@jest/fake-timers" "30.0.5" - "@jest/types" "30.0.5" + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - jest-mock "30.0.5" - jest-util "30.0.5" - jest-validate "30.0.5" + jest-mock "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" -jest-haste-map@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.0.5.tgz#fdd0daa322b02eb34267854cff2859fae21e92a6" - integrity sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg== +jest-haste-map@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.2.0.tgz#808e3889f288603ac70ff0ac047598345a66022e" + integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== dependencies: - "@jest/types" "30.0.5" + "@jest/types" "30.2.0" "@types/node" "*" anymatch "^3.1.3" fb-watchman "^2.0.2" graceful-fs "^4.2.11" jest-regex-util "30.0.1" - jest-util "30.0.5" - jest-worker "30.0.5" + jest-util "30.2.0" + jest-worker "30.2.0" micromatch "^4.0.8" walker "^1.0.8" optionalDependencies: fsevents "^2.3.3" -jest-leak-detector@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.0.5.tgz#00cfd2b323f48d8f4416b0a3e05fcf4c51f18864" - integrity sha512-3Uxr5uP8jmHMcsOtYMRB/zf1gXN3yUIc+iPorhNETG54gErFIiUhLvyY/OggYpSMOEYqsmRxmuU4ZOoX5jpRFg== +jest-leak-detector@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz#292fdca7b7c9cf594e1e570ace140b01d8beb736" + integrity sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ== dependencies: - "@jest/get-type" "30.0.1" - pretty-format "30.0.5" + "@jest/get-type" "30.1.0" + pretty-format "30.2.0" jest-matcher-utils@30.0.5: version "30.0.5" @@ -3607,6 +3652,16 @@ jest-matcher-utils@30.0.5: jest-diff "30.0.5" pretty-format "30.0.5" +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + jest-message-util@30.0.5: version "30.0.5" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.0.5.tgz#dd12ffec91dd3fa6a59cbd538a513d8e239e070c" @@ -3622,6 +3677,21 @@ jest-message-util@30.0.5: slash "^3.0.0" stack-utils "^2.0.6" +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + stack-utils "^2.0.6" + jest-mock@30.0.5: version "30.0.5" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.0.5.tgz#ef437e89212560dd395198115550085038570bdd" @@ -3631,6 +3701,15 @@ jest-mock@30.0.5: "@types/node" "*" jest-util "30.0.5" +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + jest-util "30.2.0" + jest-pnp-resolver@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" @@ -3641,108 +3720,108 @@ jest-regex-util@30.0.1: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== -jest-resolve-dependencies@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.5.tgz#53be4c51d296c84a0e75608e7b77b6fe92dbac29" - integrity sha512-/xMvBR4MpwkrHW4ikZIWRttBBRZgWK4d6xt3xW1iRDSKt4tXzYkMkyPfBnSCgv96cpkrctfXs6gexeqMYqdEpw== +jest-resolve-dependencies@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz#3370e2c0b49cc560f6a7e8ec3a59dd99525e1a55" + integrity sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w== dependencies: jest-regex-util "30.0.1" - jest-snapshot "30.0.5" + jest-snapshot "30.2.0" -jest-resolve@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.0.5.tgz#f52f91600070b7073db465dc553eee5471ea8e06" - integrity sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg== +jest-resolve@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.2.0.tgz#2e2009cbd61e8f1f003355d5ec87225412cebcd7" + integrity sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A== dependencies: chalk "^4.1.2" graceful-fs "^4.2.11" - jest-haste-map "30.0.5" + jest-haste-map "30.2.0" jest-pnp-resolver "^1.2.3" - jest-util "30.0.5" - jest-validate "30.0.5" + jest-util "30.2.0" + jest-validate "30.2.0" slash "^3.0.0" unrs-resolver "^1.7.11" -jest-runner@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.0.5.tgz#5cbaaf85964246da4f65d697f186846f23cd9b5a" - integrity sha512-JcCOucZmgp+YuGgLAXHNy7ualBx4wYSgJVWrYMRBnb79j9PD0Jxh0EHvR5Cx/r0Ce+ZBC4hCdz2AzFFLl9hCiw== +jest-runner@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.2.0.tgz#c62b4c3130afa661789705e13a07bdbcec26a114" + integrity sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ== dependencies: - "@jest/console" "30.0.5" - "@jest/environment" "30.0.5" - "@jest/test-result" "30.0.5" - "@jest/transform" "30.0.5" - "@jest/types" "30.0.5" + "@jest/console" "30.2.0" + "@jest/environment" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" chalk "^4.1.2" emittery "^0.13.1" exit-x "^0.2.2" graceful-fs "^4.2.11" - jest-docblock "30.0.1" - jest-environment-node "30.0.5" - jest-haste-map "30.0.5" - jest-leak-detector "30.0.5" - jest-message-util "30.0.5" - jest-resolve "30.0.5" - jest-runtime "30.0.5" - jest-util "30.0.5" - jest-watcher "30.0.5" - jest-worker "30.0.5" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-haste-map "30.2.0" + jest-leak-detector "30.2.0" + jest-message-util "30.2.0" + jest-resolve "30.2.0" + jest-runtime "30.2.0" + jest-util "30.2.0" + jest-watcher "30.2.0" + jest-worker "30.2.0" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.0.5.tgz#d6a7e22687264240d1786d6f7682ac6a2872e552" - integrity sha512-7oySNDkqpe4xpX5PPiJTe5vEa+Ak/NnNz2bGYZrA1ftG3RL3EFlHaUkA1Cjx+R8IhK0Vg43RML5mJedGTPNz3A== +jest-runtime@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.2.0.tgz#395ea792cde048db1b0cd1a92dc9cb9f1921bf8a" + integrity sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg== dependencies: - "@jest/environment" "30.0.5" - "@jest/fake-timers" "30.0.5" - "@jest/globals" "30.0.5" + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/globals" "30.2.0" "@jest/source-map" "30.0.1" - "@jest/test-result" "30.0.5" - "@jest/transform" "30.0.5" - "@jest/types" "30.0.5" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" chalk "^4.1.2" cjs-module-lexer "^2.1.0" collect-v8-coverage "^1.0.2" glob "^10.3.10" graceful-fs "^4.2.11" - jest-haste-map "30.0.5" - jest-message-util "30.0.5" - jest-mock "30.0.5" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" jest-regex-util "30.0.1" - jest-resolve "30.0.5" - jest-snapshot "30.0.5" - jest-util "30.0.5" + jest-resolve "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.0.5.tgz#6600716eef2e6d8ea1dd788ae4385f3a2791b11f" - integrity sha512-T00dWU/Ek3LqTp4+DcW6PraVxjk28WY5Ua/s+3zUKSERZSNyxTqhDXCWKG5p2HAJ+crVQ3WJ2P9YVHpj1tkW+g== +jest-snapshot@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.2.0.tgz#266fbbb4b95fc4665ce6f32f1f38eeb39f4e26d0" + integrity sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA== dependencies: "@babel/core" "^7.27.4" "@babel/generator" "^7.27.5" "@babel/plugin-syntax-jsx" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.27.1" "@babel/types" "^7.27.3" - "@jest/expect-utils" "30.0.5" - "@jest/get-type" "30.0.1" - "@jest/snapshot-utils" "30.0.5" - "@jest/transform" "30.0.5" - "@jest/types" "30.0.5" - babel-preset-current-node-syntax "^1.1.0" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + "@jest/snapshot-utils" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + babel-preset-current-node-syntax "^1.2.0" chalk "^4.1.2" - expect "30.0.5" + expect "30.2.0" graceful-fs "^4.2.11" - jest-diff "30.0.5" - jest-matcher-utils "30.0.5" - jest-message-util "30.0.5" - jest-util "30.0.5" - pretty-format "30.0.5" + jest-diff "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + pretty-format "30.2.0" semver "^7.7.2" synckit "^0.11.8" @@ -3758,52 +3837,64 @@ jest-util@30.0.5: graceful-fs "^4.2.11" picomatch "^4.0.2" -jest-validate@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.0.5.tgz#d26fd218b8d566bff48fd98880b8ea94fd0d8456" - integrity sha512-ouTm6VFHaS2boyl+k4u+Qip4TSH7Uld5tyD8psQ8abGgt2uYYB8VwVfAHWHjHc0NWmGGbwO5h0sCPOGHHevefw== +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== dependencies: - "@jest/get-type" "30.0.1" - "@jest/types" "30.0.5" + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + +jest-validate@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.2.0.tgz#273eaaed4c0963b934b5b31e96289edda6e0a2ef" + integrity sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw== + dependencies: + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" camelcase "^6.3.0" chalk "^4.1.2" leven "^3.1.0" - pretty-format "30.0.5" + pretty-format "30.2.0" -jest-watcher@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.0.5.tgz#90db6e3f582b88085bde58f7555cbdd3a1beb10d" - integrity sha512-z9slj/0vOwBDBjN3L4z4ZYaA+pG56d6p3kTUhFRYGvXbXMWhXmb/FIxREZCD06DYUwDKKnj2T80+Pb71CQ0KEg== +jest-watcher@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.2.0.tgz#f9c055de48e18c979e7756a3917e596e2d69b07b" + integrity sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg== dependencies: - "@jest/test-result" "30.0.5" - "@jest/types" "30.0.5" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" ansi-escapes "^4.3.2" chalk "^4.1.2" emittery "^0.13.1" - jest-util "30.0.5" + jest-util "30.2.0" string-length "^4.0.2" -jest-worker@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.0.5.tgz#0b85cbab10610303e8d84e214f94d8f052c3cd04" - integrity sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ== +jest-worker@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.2.0.tgz#fd5c2a36ff6058ec8f74366ec89538cc99539d26" + integrity sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g== dependencies: "@types/node" "*" "@ungap/structured-clone" "^1.3.0" - jest-util "30.0.5" + jest-util "30.2.0" merge-stream "^2.0.0" supports-color "^8.1.1" -jest@30.0.5: - version "30.0.5" - resolved "https://registry.yarnpkg.com/jest/-/jest-30.0.5.tgz#ee62729fb77829790d67c660d852350fbde315ce" - integrity sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ== +jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.2.0.tgz#9f0a71e734af968f26952b5ae4b724af82681630" + integrity sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A== dependencies: - "@jest/core" "30.0.5" - "@jest/types" "30.0.5" + "@jest/core" "30.2.0" + "@jest/types" "30.2.0" import-local "^3.2.0" - jest-cli "30.0.5" + jest-cli "30.2.0" joycon@^3.1.1: version "3.1.1" @@ -4403,6 +4494,15 @@ pretty-format@30.0.5, pretty-format@^30.0.0: ansi-styles "^5.2.0" react-is "^18.3.1" +pretty-format@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== + dependencies: + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -4607,6 +4707,11 @@ semver@^7.5.3, semver@^7.5.4, semver@^7.7.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -4977,10 +5082,10 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -ts-jest@29.4.1: - version "29.4.1" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.1.tgz#42d33beb74657751d315efb9a871fe99e3b9b519" - integrity sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw== +ts-jest@29.4.5: + version "29.4.5" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.5.tgz#a6b0dc401e521515d5342234be87f1ca96390a6f" + integrity sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q== dependencies: bs-logger "^0.2.6" fast-json-stable-stringify "^2.1.0" @@ -4988,7 +5093,7 @@ ts-jest@29.4.1: json5 "^2.2.3" lodash.memoize "^4.1.2" make-error "^1.3.6" - semver "^7.7.2" + semver "^7.7.3" type-fest "^4.41.0" yargs-parser "^21.1.1" From 6eaa272464ab5840d2c669960130015557d0955f Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Sun, 26 Oct 2025 16:03:02 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20improvement:=20use=20n?= =?UTF-8?q?ative=20streams=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +++-- src/subscriptions.ts | 2 +- yarn.lock | 63 -------------------------------------------- 3 files changed, 4 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 319e8c59..8858a432 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "openapi-types": "^12.1.0", "param-case": "^3.0.4", "qs": "^6.11.2", - "readable-stream": "4.7.0", "title-case": "^3.0.3", "tslib": "^2.5.0" }, @@ -70,19 +69,19 @@ "release": "yarn build && changeset publish" }, "devDependencies": { - "@changesets/cli": "2.29.7", "@babel/core": "7.28.4", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/preset-env": "7.28.3", "@babel/preset-typescript": "7.27.1", "@changesets/changelog-github": "0.5.1", + "@changesets/cli": "2.29.7", "@types/express": "5.0.3", "@types/jest": "30.0.0", - "@types/readable-stream": "4.0.22", "@types/node": "24.9.0", + "@types/qs": "6.14.0", + "@types/readable-stream": "4.0.22", "@types/swagger-ui-dist": "3.30.6", "@types/yamljs": "0.2.34", - "@types/qs": "6.14.0", "babel-jest": "30.2.0", "bob-the-bundler": "7.0.1", "chalk": "^5.4.1", diff --git a/src/subscriptions.ts b/src/subscriptions.ts index b588c45a..c1040dba 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -13,7 +13,7 @@ import { getOperationInfo } from './ast.js'; import { parseVariable } from './parse.js'; import { logger } from './logger.js'; import { ObjMap } from 'graphql/jsutils/ObjMap.js'; -import { pipeline, Writable } from 'readable-stream'; +import { pipeline, Writable } from 'stream'; type SubscriptionFieldName = string; type ID = string; diff --git a/yarn.lock b/yarn.lock index 3d1245a8..ef58fcd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2238,13 +2238,6 @@ "@whatwg-node/promise-helpers" "^1.3.2" tslib "^2.6.3" -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - acorn@^8.15.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" @@ -2415,11 +2408,6 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - baseline-browser-mapping@^2.8.3: version "2.8.6" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz#c37dea4291ed8d01682f85661dbe87967028642e" @@ -2501,14 +2489,6 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" - integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.2.1" - bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" @@ -2891,21 +2871,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== -events@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - execa@7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-7.1.1.tgz#3eb3c83d239488e7b409d48e8813b76bb55c9c43" @@ -3292,11 +3262,6 @@ iconv-lite@^0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - ignore@^5.2.0, ignore@^5.2.4: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -4398,11 +4363,6 @@ pretty-format@30.2.0, pretty-format@^30.0.0: ansi-styles "^5.2.0" react-is "^18.3.1" -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== - punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -4445,17 +4405,6 @@ read-yaml-file@^1.1.0: pify "^4.0.1" strip-bom "^3.0.0" -readable-stream@4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" - integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== - dependencies: - abort-controller "^3.0.0" - buffer "^6.0.3" - events "^3.3.0" - process "^0.11.10" - string_decoder "^1.3.0" - readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -4583,11 +4532,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -4780,13 +4724,6 @@ string-width@^8.0.0: get-east-asian-width "^1.3.0" strip-ansi "^7.1.0" -string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" From 79e3d3f32d980ff1861b88ee2554c3f13b6fab4b Mon Sep 17 00:00:00 2001 From: m1212e <14091540+m1212e@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:32:14 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=E2=86=A9=EF=B8=8F=20revert:=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/subscriptions.ts | 104 +++++++++++++++++-------------------------- 2 files changed, 43 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index 8858a432..b3d2ca52 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "clean": "rm -rf dist", "prebuild": "yarn clean", "build": "bob build --single", - "test": "jest --no-watchman --detectOpenHandles --logHeapUsage", + "test": "jest --no-watchman --detectOpenHandles --detectLeaks", "release": "yarn build && changeset publish" }, "devDependencies": { diff --git a/src/subscriptions.ts b/src/subscriptions.ts index c1040dba..160bfa1f 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -13,7 +13,6 @@ import { getOperationInfo } from './ast.js'; import { parseVariable } from './parse.js'; import { logger } from './logger.js'; import { ObjMap } from 'graphql/jsutils/ObjMap.js'; -import { pipeline, Writable } from 'stream'; type SubscriptionFieldName = string; type ID = string; @@ -36,9 +35,9 @@ interface BuiltOperation { } interface StoredClient { + url: string; subscriptionName: SubscriptionFieldName; rx: AsyncIterator; - tx: Writable; timeoutHandle?: NodeJS.Timeout; } @@ -125,18 +124,41 @@ export function createSubscriptionManager(sofa: Sofa) { return subscriptionIterable; }; - const mergeTxRxStreams = (id: ID, rx: AsyncIterable, tx: Writable) => { - pipeline(rx, tx, (err) => { - if (err) { - logger.error(`[Subscription] Pipeline error on ${id}: ${err.message}`); - stop(id, 'Subscription pipeline errored out'); - } - // in case the rx is done without the stop function being called (and therefore performing a deletion of the client), - // we want to make sure we clean up the client entry properly - if (clients.has(id)) { - stop(id, 'Subscription finished, no further data available'); - } + const sendMessage = async (message: any, url: string) => { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(message), + headers: { + 'Content-Type': 'application/json', + }, }); + + if (!response.ok) { + throw new Error( + `Failed to send data to ${url}: ${response.status} ${response.statusText}` + ); + } + + response.body?.cancel(); // We don't care about the response body but want to free up resources + }; + + const startMessaging = (id: string, url: string, rx: AsyncIterable) => { + (async () => { + for await (const message of rx) { + try { + await sendMessage(message, url); + logger.debug(`[Subscription] Sent message to ${url}`, message); + } catch (error) { + logger.error( + `[Subscription] Error sending message to ${url}:`, + error + ); + stop(id, `Subscription stopped due to delivery error`); + break; + } + } + stop(id, 'Subscription completed gracefully'); + })(); }; const start = async ( @@ -153,53 +175,12 @@ export function createSubscriptionManager(sofa: Sofa) { contextValue ); - const tx = new Writable({ - objectMode: true, - highWaterMark: 100, - async write(message, _encoding, callback) { - logger.info(`[Subscription] Trigger ${id}`); - - try { - const response = await fetch(event.url, { - method: 'POST', - body: JSON.stringify(message), - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - logger.error( - `[Subscription] Failed to send data for ${id} to ${event.url}: ${response.status} ${response.statusText}` - ); - callback( - new Error( - `Failed to send data for ${id} to ${event.url}: ${response.status} ${response.statusText}` - ) - ); - return; - } - - response.body?.cancel(); // We don't care about the response body but want to free up resources - callback(); - } catch (err) { - callback(err instanceof Error ? err : new Error(String(err))); - } - }, - }); - - tx.on('error', (err) => { - logger.error(`[Subscription] Error on ${id}: ${err.message}`); - stop(id, 'Subscription errored out (tx)'); - }); - - mergeTxRxStreams(id, rx, tx); - + startMessaging(id, event.url, rx); clients.set(id, { + url: event.url, subscriptionName, rx, - tx, timeoutHandle: sofa.webhooks?.maxSubscriptionWebhookLifetimeSeconds ? setTimeout(() => { stop(id, 'Max subscription lifetime reached'); @@ -225,9 +206,10 @@ export function createSubscriptionManager(sofa: Sofa) { const client = clients.get(id); if (!client) { - throw new Error( - `Subscription with ID '${id}' does not exist (${terminationReason})` + logger.warn( + `Subscription with ID '${id}' does not exist (${terminationReason}), might have been already stopped. Skipping stop.` ); + return { id }; } if (sofa.webhooks?.terminationMessage && terminationReason !== null) { @@ -253,7 +235,7 @@ export function createSubscriptionManager(sofa: Sofa) { }, }, }; - client.tx.write(terminationMessage); + await sendMessage(terminationMessage, client.url); } if (client.timeoutHandle) { @@ -264,8 +246,6 @@ export function createSubscriptionManager(sofa: Sofa) { if (client.rx.return) { await client.rx.return(); } - // clear the sending stream since we are done - client.tx.end(); // remove the client from the map clients.delete(id); @@ -293,7 +273,7 @@ export function createSubscriptionManager(sofa: Sofa) { contextValue ); - mergeTxRxStreams(event.id, rx, client.tx); + startMessaging(event.id, client.url, rx); client.rx = rx; return { id: event.id }; };