Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@
"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",
"title-case": "^3.0.3",
"tslib": "^2.5.0"
},
"scripts": {
Expand All @@ -69,18 +69,19 @@
"release": "yarn build && changeset publish"
},
"devDependencies": {
"@changesets/changelog-github": "0.5.1",
"@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/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",
Expand Down Expand Up @@ -111,4 +112,4 @@
"access": "public"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}
154 changes: 78 additions & 76 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
}
Expand Down
41 changes: 41 additions & 0 deletions src/sofa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,45 @@ export interface SofaConfig {

openAPI?: RouterOpenAPIOptions<any>;
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 });
};
}

export interface Sofa {
Expand All @@ -79,6 +118,8 @@ export interface Sofa {

openAPI?: RouterOpenAPIOptions<any>;
swaggerUI?: RouterSwaggerUIOptions;

webhooks?: SofaConfig['webhooks'];
}

export function createSofa(config: SofaConfig): Sofa {
Expand Down
Loading