Skip to content

Commit c834def

Browse files
feat: webhooks (#4)
1 parent 3f767ac commit c834def

File tree

11 files changed

+186
-13
lines changed

11 files changed

+186
-13
lines changed

.vscode/extensions.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"recommendations": [
3+
"oxc.oxc-vscode",
4+
"typescriptteam.native-preview"
5+
]
6+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"lint": "turbo lint",
2626
"prepare": "husky",
2727
"release": "node ./scripts/release.ts",
28-
"server": "NODE_ENV=development OTEL_DENO=true deno serve -REW --unstable-raw-imports --watch ./scripts/server.ts",
28+
"server": "NODE_ENV=development OTEL_DENO=true deno serve -REW --watch ./scripts/server.ts",
2929
"test": "turbo test",
3030
"verify": "turbo verify --filter='./packages/*'"
3131
},

packages/core/deno.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
"./gcp-firestore": "./dist/gcp-firestore.mjs",
3838
"./gcp-storage": "./dist/gcp-storage.mjs",
3939
"./mysql": "./dist/mysql.mjs",
40-
"./redis": "./dist/redis.mjs",
41-
"./openapi.json": "./openapi.json"
40+
"./redis": "./dist/redis.mjs"
4241
}
4342
}

packages/core/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@
135135
"./gcp-storage": "./dist/gcp-storage.mjs",
136136
"./mysql": "./dist/mysql.mjs",
137137
"./redis": "./dist/redis.mjs",
138-
"./package.json": "./package.json",
139-
"./openapi.json": "./openapi.json"
138+
"./package.json": "./package.json"
140139
}
141140
}

packages/core/src/models/builds-model.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { checkAuthorisation } from "../utils/auth.ts";
1212
import { mimes } from "../utils/mime-utils.ts";
1313
import { getStore } from "../utils/store.ts";
14+
import { dispatchWebhooks } from "../utils/webhooks.ts";
1415
import {
1516
BuildSchema,
1617
type BuildCreateType,
@@ -82,6 +83,14 @@ export class BuildsModel extends Model<BuildType> {
8283
try {
8384
const projectsModel = new ProjectsModel();
8485
const project = await projectsModel.get(this.projectId);
86+
87+
// Do not await, fire and forget
88+
dispatchWebhooks("build:created", {
89+
projectId: this.projectId,
90+
payload: build,
91+
projectHooks: project.webhooks,
92+
});
93+
8594
if (tags.includes(project.gitHubDefaultBranch)) {
8695
await projectsModel.update(this.projectId, { latestBuildId: id });
8796
}
@@ -125,6 +134,13 @@ export class BuildsModel extends Model<BuildType> {
125134
{ ...data, updatedAt: new Date().toISOString() },
126135
this.dbOptions,
127136
);
137+
138+
// Do not await, fire and forget
139+
dispatchWebhooks("build:updated", {
140+
projectId: this.projectId,
141+
payload: data,
142+
projectHooks: await new ProjectsModel().getWebhooks(this.projectId),
143+
});
128144
}
129145

130146
async delete(buildId: string, updateTag = true): Promise<void> {
@@ -166,6 +182,14 @@ export class BuildsModel extends Model<BuildType> {
166182
try {
167183
const projectsModel = new ProjectsModel();
168184
const project = await projectsModel.get(this.projectId);
185+
186+
// Do not await, fire and forget
187+
dispatchWebhooks("build:deleted", {
188+
projectId: project.id,
189+
payload: build,
190+
projectHooks: project.webhooks,
191+
});
192+
169193
if (project.latestBuildId === buildId) {
170194
this.debug("Update project for build '%s'", buildId);
171195
await projectsModel.update(this.projectId, {

packages/core/src/models/projects-model.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateStorageContainerId,
66
} from "../utils/adapter-utils.ts";
77
import { checkAuthorisation } from "../utils/auth.ts";
8+
import { dispatchWebhooks, type WebhookEntry } from "../utils/webhooks.ts";
89
import {
910
ProjectSchema,
1011
type ProjectCreateType,
@@ -87,6 +88,13 @@ export class ProjectsModel extends Model<ProjectType> {
8788
};
8889
await this.database.createDocument(this.collectionId, project, this.dbOptions);
8990

91+
// Do not await, fire and forget
92+
dispatchWebhooks("project:created", {
93+
projectId: project.id,
94+
payload: project,
95+
projectHooks: project.webhooks,
96+
});
97+
9098
return project;
9199
} catch (error) {
92100
throw new HTTPException(500, {
@@ -124,6 +132,13 @@ export class ProjectsModel extends Model<ProjectType> {
124132
this.dbOptions,
125133
);
126134

135+
// Do not await, fire and forget
136+
dispatchWebhooks("project:updated", {
137+
projectId: id,
138+
payload: data,
139+
projectHooks: await this.getWebhooks(id),
140+
});
141+
127142
if (data.gitHubDefaultBranch) {
128143
try {
129144
this.debug("Create default-branch tag '%s'...", data.gitHubDefaultBranch);
@@ -139,10 +154,18 @@ export class ProjectsModel extends Model<ProjectType> {
139154

140155
async delete(id: string): Promise<void> {
141156
this.log("Delete project '%s'...", id);
157+
const project = await this.get(id);
142158

143159
this.debug("Delete project entry '%s' in collection", id);
144160
await this.database.deleteDocument(this.collectionId, id, this.dbOptions);
145161

162+
// Do not await, fire and forget
163+
dispatchWebhooks("project:deleted", {
164+
projectId: id,
165+
payload: project,
166+
projectHooks: project.webhooks,
167+
});
168+
146169
this.debug("Delete project-builds collection");
147170
await this.database.deleteCollection(
148171
generateDatabaseCollectionId(id, "Builds"),
@@ -174,4 +197,9 @@ export class ProjectsModel extends Model<ProjectType> {
174197
update: this.update.bind(this, id),
175198
};
176199
};
200+
201+
async getWebhooks(projectOrId: string | { webhooks?: WebhookEntry[] }): Promise<WebhookEntry[]> {
202+
const project = typeof projectOrId === "string" ? await this.get(projectOrId) : projectOrId;
203+
return project.webhooks || [];
204+
}
177205
}

packages/core/src/models/projects-schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "@hono/zod-openapi";
22
import { DEFAULT_GITHUB_BRANCH, DEFAULT_PURGE_AFTER_DAYS } from "../utils/constants.ts";
3+
import { WEBHOOK_EVENTS } from "../utils/webhooks.ts";
34
import { BuildIdSchema, ProjectIdSchema } from "./~shared-schema.ts";
45

56
export { ProjectIdSchema };
@@ -38,6 +39,16 @@ export const ProjectSchema = z
3839
}),
3940

4041
updatedAt: z.iso.datetime().default(new Date().toISOString()),
42+
43+
webhooks: z
44+
.array(
45+
z.object({
46+
headers: z.record(z.string(), z.string()).optional(),
47+
events: z.array(z.enum(WEBHOOK_EVENTS)).optional(),
48+
url: z.url(),
49+
}),
50+
)
51+
.optional(),
4152
})
4253
.meta({ id: "Project", title: "StoryBooker project" });
4354

packages/core/src/models/tags-model.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { HTTPException } from "hono/http-exception";
22
import type { StoryBookerPermissionAction } from "../adapters/_internal/auth.ts";
33
import { generateDatabaseCollectionId } from "../utils/adapter-utils.ts";
44
import { checkAuthorisation } from "../utils/auth.ts";
5+
import { dispatchWebhooks } from "../utils/webhooks.ts";
56
import { BuildsModel } from "./builds-model.ts";
67
import { ProjectsModel } from "./projects-model.ts";
78
import { TagSchema, type TagCreateType, type TagType, type TagUpdateType } from "./tags-schema.ts";
@@ -41,6 +42,13 @@ export class TagsModel extends Model<TagType> {
4142
};
4243
await this.database.createDocument(this.collectionId, tag, this.dbOptions);
4344

45+
// Do not await, fire and forget
46+
dispatchWebhooks("tag:updated", {
47+
projectId: this.projectId,
48+
payload: tag,
49+
projectHooks: await new ProjectsModel().getWebhooks(this.projectId),
50+
});
51+
4452
return tag;
4553
} catch (error) {
4654
throw new HTTPException(500, {
@@ -76,20 +84,35 @@ export class TagsModel extends Model<TagType> {
7684
{ ...data, updatedAt: new Date().toISOString() },
7785
this.dbOptions,
7886
);
87+
88+
// Do not await, fire and forget
89+
dispatchWebhooks("tag:updated", {
90+
projectId: this.projectId,
91+
payload: data,
92+
projectHooks: await new ProjectsModel().getWebhooks(this.projectId),
93+
});
7994
}
8095

8196
async delete(id: string): Promise<void> {
8297
this.log("Delete tag '%s'...", id);
98+
const tag = await this.get(id);
8399

84-
const { gitHubDefaultBranch } = await new ProjectsModel().get(this.projectId);
85-
if (id === TagsModel.createId(gitHubDefaultBranch)) {
86-
const message = `Cannot delete the tag associated with default branch (${gitHubDefaultBranch}) of the project '${this.projectId}'.`;
100+
const project = await new ProjectsModel().get(this.projectId);
101+
if (id === TagsModel.createId(project.gitHubDefaultBranch)) {
102+
const message = `Cannot delete the tag associated with default branch (${project.gitHubDefaultBranch}) of the project '${this.projectId}'.`;
87103
this.error(message);
88104
throw new Error(message);
89105
}
90106

91107
await this.database.deleteDocument(this.collectionId, id, this.dbOptions);
92108

109+
// Do not await, fire and forget
110+
dispatchWebhooks("tag:updated", {
111+
projectId: this.projectId,
112+
payload: tag,
113+
projectHooks: project.webhooks,
114+
});
115+
93116
try {
94117
this.debug("Delete builds associated with tag '%s'...", id);
95118
await new BuildsModel(this.projectId).deleteByTag(id, false);

packages/core/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { LoggerAdapter } from "./adapters/_internal/logger.ts";
55
import type { StorageAdapter } from "./adapters/_internal/storage.ts";
66
import type { UIAdapter } from "./adapters/_internal/ui.ts";
77
import type { ErrorParser } from "./utils/error.ts";
8+
import type { WebhookEntry } from "./utils/webhooks.ts";
89

910
export type {
1011
StoryBookerUser,
@@ -101,4 +102,8 @@ export interface RouterConfig {
101102
* Add Hono middlewares to the router before any endpoint is registered/invoked.
102103
*/
103104
middlewares?: MiddlewareHandler[];
105+
/**
106+
* Pre-configured webhooks to use for dispatching events (all projects).
107+
*/
108+
webhooks?: WebhookEntry[];
104109
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { getStore } from "./store";
2+
3+
export const WEBHOOK_EVENTS = [
4+
"build:created",
5+
"build:deleted",
6+
"build:updated",
7+
"project:created",
8+
"project:deleted",
9+
"project:updated",
10+
"tag:created",
11+
"tag:deleted",
12+
"tag:updated",
13+
] as const;
14+
15+
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
16+
17+
/**
18+
* Webhook event to their corresponding URL.
19+
*/
20+
export interface WebhookEntry {
21+
url: string;
22+
headers?: Record<string, string>;
23+
events?: WebhookEvent[];
24+
}
25+
26+
export async function dispatchWebhooks(
27+
event: WebhookEvent,
28+
options: {
29+
projectId: string;
30+
payload: Record<string, unknown>;
31+
timeoutMs?: number;
32+
projectHooks?: WebhookEntry[];
33+
},
34+
): Promise<void> {
35+
const { logger, config } = getStore();
36+
const { projectId, payload, timeoutMs = 5000, projectHooks = [] } = options;
37+
38+
const configHooks =
39+
config?.webhooks?.filter((hook) => !hook.events || hook.events.includes(event)) ?? [];
40+
const allHooks = [...configHooks, ...projectHooks];
41+
42+
if (!allHooks || allHooks.length === 0) {
43+
return;
44+
}
45+
46+
// Run in parallel but don't throw — we only want best-effort delivery here
47+
await Promise.allSettled(
48+
allHooks.map(async (hook) => {
49+
const { url, headers } = hook;
50+
51+
try {
52+
const res = await fetch(url, {
53+
method: "POST",
54+
headers: { ...headers, "content-type": "application/json", "x-webhook-event": event },
55+
body: JSON.stringify({ event, projectId, payload }),
56+
signal: AbortSignal.timeout(timeoutMs),
57+
});
58+
logger?.log?.("[webhook] (%s) %s - %s", event, url, res.status);
59+
return { url, ok: res.ok, status: res.status };
60+
} catch (error) {
61+
logger?.error?.("[webhook] ERROR (%s) %s - $s", event, url, error);
62+
return { url, ok: false, error: error instanceof Error ? error.message : String(error) };
63+
}
64+
}),
65+
);
66+
}

0 commit comments

Comments
 (0)