okengine 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +148 -13
  2. package/package.json +3 -3
  3. package/site/content/docs/elements/channel.mdx +71 -7
  4. package/site/content/docs/reference/configuration.mdx +5 -4
  5. package/site/content/docs/reference/environment-variables.mdx +32 -8
  6. package/src/cli/openbao-restart.integration.test.ts +106 -97
  7. package/src/docker/dockerfile.integration.test.ts +126 -119
  8. package/src/docker/stack.integration.test.ts +118 -102
  9. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  10. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  11. package/src/drivers/channel-fcm.ts +49 -53
  12. package/src/drivers/channel-msegat.ts +61 -0
  13. package/src/drivers/channel-sently-map.ts +57 -0
  14. package/src/drivers/channel-sently.test.ts +99 -0
  15. package/src/drivers/channel-sndr.ts +28 -0
  16. package/src/drivers/channel-taqnyat.ts +57 -0
  17. package/src/drivers/channel-types.ts +79 -2
  18. package/src/drivers/channel-unifonic.ts +26 -43
  19. package/src/drivers/channel-wa-cloud.ts +33 -47
  20. package/src/drivers/channel-webpush.ts +39 -239
  21. package/src/drivers/index.ts +4 -0
  22. package/src/elements/channel/costs.test.ts +2 -2
  23. package/src/elements/channel/costs.ts +14 -2
  24. package/src/elements/channel/mime.ts +11 -0
  25. package/src/elements/channel/runtime.ts +94 -0
  26. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  27. package/src/elements/channel.ts +10 -1
  28. package/src/elements/index.ts +9 -0
  29. package/src/kernel/boot-bind/channel.test.ts +68 -3
  30. package/src/kernel/boot-bind/channel.ts +93 -2
  31. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  32. package/src/release/exports.test.ts +26 -0
  33. package/src/release/exports.ts +64 -5
  34. package/src/release/index.ts +5 -0
  35. package/src/release/measure.exports.test.ts +13 -1
  36. package/src/release/measure.ts +76 -13
  37. package/src/release/official-plugins.ts +46 -0
  38. package/src/release/readme.test.ts +30 -2
@@ -1,15 +1,34 @@
1
1
  /**
2
- * Channel binder — profile selection + SMTP env parsing.
2
+ * Channel binder — profile selection + env parsing.
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { resolveEmailDriverId, smtpOptionsFromEnv } from "./channel.ts";
6
+ import {
7
+ msegatOptionsFromEnv,
8
+ resendOptionsFromEnv,
9
+ resolveEmailDriverId,
10
+ resolveSmsDriverId,
11
+ smtpOptionsFromEnv,
12
+ sndrOptionsFromEnv,
13
+ taqnyatOptionsFromEnv,
14
+ unifonicOptionsFromEnv,
15
+ } from "./channel.ts";
7
16
 
8
17
  const previous = {
9
18
  url: process.env.SMTP_URL,
10
19
  user: process.env.SMTP_USER,
11
20
  password: process.env.SMTP_PASSWORD,
12
21
  okeUrl: process.env.OKE_CHANNEL_EMAIL_URL,
22
+ resend: process.env.RESEND_API_KEY,
23
+ sndr: process.env.SNDR_API_KEY,
24
+ sndrBase: process.env.SNDR_BASE_URL,
25
+ taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
26
+ taqSender: process.env.TAQNYAT_SENDER,
27
+ msegatUser: process.env.MSEGAT_USERNAME,
28
+ msegatKey: process.env.MSEGAT_API_KEY,
29
+ msegatSender: process.env.MSEGAT_SENDER,
30
+ unifonicSid: process.env.UNIFONIC_APPSID,
31
+ unifonicSender: process.env.UNIFONIC_SENDER,
13
32
  };
14
33
 
15
34
  afterEach(() => {
@@ -17,6 +36,16 @@ afterEach(() => {
17
36
  restoreEnv("SMTP_USER", previous.user);
18
37
  restoreEnv("SMTP_PASSWORD", previous.password);
19
38
  restoreEnv("OKE_CHANNEL_EMAIL_URL", previous.okeUrl);
39
+ restoreEnv("RESEND_API_KEY", previous.resend);
40
+ restoreEnv("SNDR_API_KEY", previous.sndr);
41
+ restoreEnv("SNDR_BASE_URL", previous.sndrBase);
42
+ restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
43
+ restoreEnv("TAQNYAT_SENDER", previous.taqSender);
44
+ restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
45
+ restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
46
+ restoreEnv("MSEGAT_SENDER", previous.msegatSender);
47
+ restoreEnv("UNIFONIC_APPSID", previous.unifonicSid);
48
+ restoreEnv("UNIFONIC_SENDER", previous.unifonicSender);
20
49
  });
21
50
 
22
51
  describe("bindChannel driver resolution", () => {
@@ -24,7 +53,8 @@ describe("bindChannel driver resolution", () => {
24
53
  config: {
25
54
  drivers: {
26
55
  channel: {
27
- email: { local: "console", docker: "smtp", test: "console", prod: "smtp" },
56
+ email: { local: "console", docker: "smtp", test: "console", prod: "sndr" },
57
+ sms: { local: "console", docker: "taqnyat", test: "msegat", prod: "taqnyat" },
28
58
  },
29
59
  },
30
60
  },
@@ -33,6 +63,13 @@ describe("bindChannel driver resolution", () => {
33
63
  test("uses console locally and smtp in docker", () => {
34
64
  expect(resolveEmailDriverId(options, "local", false)).toBe("console");
35
65
  expect(resolveEmailDriverId(options, "docker", true)).toBe("smtp");
66
+ expect(resolveEmailDriverId(options, "prod", false)).toBe("sndr");
67
+ });
68
+
69
+ test("resolves sms driver ids", () => {
70
+ expect(resolveSmsDriverId(options, "local")).toBe("console");
71
+ expect(resolveSmsDriverId(options, "docker")).toBe("taqnyat");
72
+ expect(resolveSmsDriverId(options, "test")).toBe("msegat");
36
73
  });
37
74
 
38
75
  test("parses SMTP_URL and optional auth overrides", () => {
@@ -52,6 +89,34 @@ describe("bindChannel driver resolution", () => {
52
89
  delete process.env.OKE_CHANNEL_EMAIL_URL;
53
90
  expect(() => smtpOptionsFromEnv(true)).toThrow("docker/.env.docker");
54
91
  });
92
+
93
+ test("resend and sndr env helpers", () => {
94
+ process.env.RESEND_API_KEY = "re_x";
95
+ expect(resendOptionsFromEnv()).toEqual({ apiKey: "re_x" });
96
+ process.env.SNDR_API_KEY = "sndr_test_x";
97
+ process.env.SNDR_BASE_URL = "https://api.example.test";
98
+ expect(sndrOptionsFromEnv()).toEqual({
99
+ apiKey: "sndr_test_x",
100
+ url: "https://api.example.test",
101
+ });
102
+ });
103
+
104
+ test("taqnyat, msegat, and unifonic env helpers", () => {
105
+ process.env.TAQNYAT_BEARER_TOKEN = "bearer";
106
+ process.env.TAQNYAT_SENDER = "Brand";
107
+ expect(taqnyatOptionsFromEnv()).toEqual({ bearerToken: "bearer", sender: "Brand" });
108
+ process.env.MSEGAT_USERNAME = "user";
109
+ process.env.MSEGAT_API_KEY = "key";
110
+ process.env.MSEGAT_SENDER = "Brand";
111
+ expect(msegatOptionsFromEnv()).toEqual({
112
+ userName: "user",
113
+ apiKey: "key",
114
+ sender: "Brand",
115
+ });
116
+ process.env.UNIFONIC_APPSID = "sid";
117
+ process.env.UNIFONIC_SENDER = "Brand";
118
+ expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
119
+ });
55
120
  });
56
121
 
57
122
  function restoreEnv(key: string, value: string | undefined): void {
@@ -4,7 +4,12 @@
4
4
 
5
5
  import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
6
  import { openConsoleChannel } from "../../drivers/channel-console.ts";
7
+ import { openMsegatChannel } from "../../drivers/channel-msegat.ts";
8
+ import { openResendChannel } from "../../drivers/channel-resend.ts";
7
9
  import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
10
+ import { openSndrChannel } from "../../drivers/channel-sndr.ts";
11
+ import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
12
+ import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
8
13
  import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
9
14
  import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
10
15
  import type { BootOptions } from "../boot.ts";
@@ -12,6 +17,9 @@ import type { BootOptions } from "../boot.ts";
12
17
  /**
13
18
  * Construct a Channel runtime (console inbox default).
14
19
  *
20
+ * When `options.channel.drivers` is omitted, opens the configured email driver
21
+ * and, if `drivers.channel.sms` resolves, appends that SMS driver to the chain.
22
+ *
15
23
  * @param options - Boot options
16
24
  * @param env - Active environment
17
25
  * @param now - Clock
@@ -25,18 +33,36 @@ export function bindChannel(
25
33
  ): ChannelRuntime {
26
34
  return createChannelRuntime({
27
35
  ...(options.channel ?? {}),
28
- drivers: options.channel?.drivers ?? [channelDriverFor(options, env, docker)],
36
+ drivers: options.channel?.drivers ?? defaultDrivers(options, env, docker),
29
37
  now,
30
38
  });
31
39
  }
32
40
 
33
- function channelDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
41
+ function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver[] {
42
+ const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
43
+ const sms = smsDriverFor(options, env);
44
+ if (sms) drivers.push(sms);
45
+ return drivers;
46
+ }
47
+
48
+ function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
34
49
  const id = resolveEmailDriverId(options, env, docker);
35
50
  if (id === "console") return openConsoleChannel();
36
51
  if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
52
+ if (id === "resend") return openResendChannel(resendOptionsFromEnv());
53
+ if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
37
54
  throw new Error(`oke boot: unknown email channel driver "${id}"`);
38
55
  }
39
56
 
57
+ function smsDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
58
+ const id = resolveSmsDriverId(options, env);
59
+ if (!id || id === "console") return undefined;
60
+ if (id === "taqnyat") return openTaqnyatChannel(taqnyatOptionsFromEnv());
61
+ if (id === "msegat") return openMsegatChannel(msegatOptionsFromEnv());
62
+ if (id === "unifonic") return openUnifonicChannel(unifonicOptionsFromEnv());
63
+ throw new Error(`oke boot: unknown sms channel driver "${id}"`);
64
+ }
65
+
40
66
  /**
41
67
  * Resolve the configured email driver for one environment.
42
68
  *
@@ -54,6 +80,16 @@ export function resolveEmailDriverId(
54
80
  );
55
81
  }
56
82
 
83
+ /**
84
+ * Resolve the configured SMS driver for one environment.
85
+ *
86
+ * @param options - Boot options
87
+ * @param env - Active environment
88
+ */
89
+ export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
90
+ return resolveDriverId(options.config?.drivers?.channel?.sms, env);
91
+ }
92
+
57
93
  /**
58
94
  * Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
59
95
  *
@@ -81,3 +117,58 @@ export function smtpOptionsFromEnv(docker = false): ChannelOpenOptions {
81
117
  ...(pass ? { pass } : {}),
82
118
  };
83
119
  }
120
+
121
+ /**
122
+ * Resolve Resend options from env.
123
+ */
124
+ export function resendOptionsFromEnv(): ChannelOpenOptions {
125
+ const apiKey = process.env.RESEND_API_KEY?.trim();
126
+ if (!apiKey) throw new Error("oke boot: resend channel needs RESEND_API_KEY");
127
+ return { apiKey };
128
+ }
129
+
130
+ /**
131
+ * Resolve SNDR options from env.
132
+ */
133
+ export function sndrOptionsFromEnv(): ChannelOpenOptions {
134
+ const apiKey = process.env.SNDR_API_KEY?.trim();
135
+ if (!apiKey) throw new Error("oke boot: sndr channel needs SNDR_API_KEY");
136
+ const url = process.env.SNDR_BASE_URL?.trim();
137
+ return { apiKey, ...(url ? { url } : {}) };
138
+ }
139
+
140
+ /**
141
+ * Resolve Taqnyat SMS options from env.
142
+ */
143
+ export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
144
+ const bearerToken = process.env.TAQNYAT_BEARER_TOKEN?.trim() ?? process.env.TAQNYAT_TOKEN?.trim();
145
+ const sender = process.env.TAQNYAT_SENDER?.trim();
146
+ if (!bearerToken) {
147
+ throw new Error("oke boot: taqnyat channel needs TAQNYAT_BEARER_TOKEN");
148
+ }
149
+ if (!sender) throw new Error("oke boot: taqnyat channel needs TAQNYAT_SENDER");
150
+ return { bearerToken, sender };
151
+ }
152
+
153
+ /**
154
+ * Resolve Msegat SMS options from env.
155
+ */
156
+ export function msegatOptionsFromEnv(): ChannelOpenOptions {
157
+ const userName = process.env.MSEGAT_USERNAME?.trim();
158
+ const apiKey = process.env.MSEGAT_API_KEY?.trim();
159
+ const sender = process.env.MSEGAT_SENDER?.trim();
160
+ if (!userName) throw new Error("oke boot: msegat channel needs MSEGAT_USERNAME");
161
+ if (!apiKey) throw new Error("oke boot: msegat channel needs MSEGAT_API_KEY");
162
+ if (!sender) throw new Error("oke boot: msegat channel needs MSEGAT_SENDER");
163
+ return { userName, apiKey, sender };
164
+ }
165
+
166
+ /**
167
+ * Resolve Unifonic SMS options from env.
168
+ */
169
+ export function unifonicOptionsFromEnv(): ChannelOpenOptions {
170
+ const appSid = process.env.UNIFONIC_APPSID?.trim() ?? process.env.UNIFONIC_APP_SID?.trim();
171
+ const sender = process.env.UNIFONIC_SENDER?.trim();
172
+ if (!appSid) throw new Error("oke boot: unifonic channel needs UNIFONIC_APPSID");
173
+ return { appSid, ...(sender ? { sender } : {}) };
174
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Real Mailpit end-to-end: magic-link / email-otp request → SMTP → Mailpit API.
3
3
  *
4
- * Gated on a live Docker daemon — same real-skip pattern as pgvector /
5
- * Meilisearch (`const live = … ? test : test.skip`). Never an empty pass.
4
+ * Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon — same real-skip
5
+ * pattern as pgvector / Meilisearch (`const live = … ? test : test.skip`).
6
+ * Never an empty pass.
6
7
  */
7
8
 
8
9
  import { afterEach, describe, expect, test } from "bun:test";
@@ -36,9 +37,14 @@ function dockerAvailable(): boolean {
36
37
  }
37
38
  }
38
39
 
39
- const DOCKER = dockerAvailable();
40
+ const WANT = process.env.OKE_TEST_DOCKER === "1";
41
+ const DOCKER = WANT && dockerAvailable();
40
42
  if (!DOCKER) {
41
- console.log("skip: mailpit e2e (docker daemon not available)");
43
+ console.log(
44
+ WANT
45
+ ? "skip: mailpit e2e (docker daemon not available)"
46
+ : "skip: mailpit e2e (OKE_TEST_DOCKER≠1)",
47
+ );
42
48
  }
43
49
  const live = DOCKER ? test : test.skip;
44
50
 
@@ -9,7 +9,9 @@ import {
9
9
  exportBudgetLabel,
10
10
  isMeasurableDriverFile,
11
11
  listDriverModules,
12
+ OFFICIAL_PLUGIN_BUDGETS,
12
13
  resolveExportBudgetTargets,
14
+ resolvePluginBudgetTargets,
13
15
  } from "./exports.ts";
14
16
 
15
17
  const ROOT = resolve(import.meta.dir, "../..");
@@ -29,12 +31,32 @@ describe("export budget targets", () => {
29
31
  expect(exportBudgetLabel(".")).toBe("okengine");
30
32
  expect(exportBudgetLabel("./channel")).toBe("channel");
31
33
  expect(exportBudgetLabel("./drivers/postgres")).toBe("postgres");
34
+ expect(exportBudgetLabel("./plugins/cors")).toBe("cors");
32
35
  expect(exportBudgetGroup(".")).toBe("exports");
33
36
  expect(exportBudgetGroup("./channel")).toBe("exports");
37
+ expect(exportBudgetGroup("./plugins")).toBe("exports");
38
+ expect(exportBudgetGroup("./plugins/cors")).toBe("plugins");
34
39
  expect(exportBudgetGroup("./drivers")).toBe("drivers");
35
40
  expect(exportBudgetGroup("./drivers/postgres")).toBe("drivers");
36
41
  });
37
42
 
43
+ test("official plugin catalogue files exist and resolve uniquely", async () => {
44
+ const plugins = resolvePluginBudgetTargets();
45
+ expect(plugins.length).toBe(OFFICIAL_PLUGIN_BUDGETS.length);
46
+ expect(new Set(plugins.map((t) => t.id)).size).toBe(plugins.length);
47
+
48
+ for (const plugin of OFFICIAL_PLUGIN_BUDGETS) {
49
+ const path = join(ROOT, "src/plugins", plugin.file);
50
+ expect(await Bun.file(path).exists()).toBe(true);
51
+ const target = plugins.find((t) => t.label === plugin.name);
52
+ expect(target).toBeDefined();
53
+ expect(target!.id).toBe(`export:./plugins/${plugin.name}`);
54
+ expect(target!.group).toBe("plugins");
55
+ expect(target!.category).toBe(plugin.category);
56
+ expect(target!.entry).toBe(path);
57
+ }
58
+ });
59
+
38
60
  test("every non-glob package export and every driver module is covered", async () => {
39
61
  const pkg = (await Bun.file(join(ROOT, "package.json")).json()) as {
40
62
  exports: Record<string, string>;
@@ -65,6 +87,10 @@ describe("export budget targets", () => {
65
87
  expect(target!.entry).toBe(join(ROOT, "src/drivers", file));
66
88
  }
67
89
 
90
+ for (const plugin of OFFICIAL_PLUGIN_BUDGETS) {
91
+ expect(bySubpath.get(`./plugins/${plugin.name}`)).toBeDefined();
92
+ }
93
+
68
94
  // No duplicates.
69
95
  expect(new Set(targets.map((t) => t.id)).size).toBe(targets.length);
70
96
  });
@@ -2,22 +2,37 @@
2
2
  * Resolve published `package.json` exports into measurable bundle entries.
3
3
  *
4
4
  * Expands `./drivers/*` to concrete driver modules (tree-shaken subpaths).
5
+ * Official `okengine/plugins` named modules are sampled separately (not published
6
+ * as `./plugins/*` subpaths).
5
7
  */
6
8
 
7
9
  import { readdir } from "node:fs/promises";
8
10
  import { basename, join, resolve } from "node:path";
11
+ import {
12
+ OFFICIAL_PLUGIN_BUDGETS,
13
+ type OfficialPluginBudget,
14
+ type PluginBudgetCategory,
15
+ } from "./official-plugins.ts";
16
+
17
+ export {
18
+ OFFICIAL_PLUGIN_BUDGETS,
19
+ PLUGIN_BUDGET_CATEGORIES,
20
+ type OfficialPluginBudget,
21
+ type PluginBudgetCategory,
22
+ } from "./official-plugins.ts";
9
23
 
10
24
  const ROOT = resolve(import.meta.dir, "../..");
11
25
  const DRIVERS_DIR = join(ROOT, "src/drivers");
26
+ const PLUGINS_DIR = join(ROOT, "src/plugins");
12
27
 
13
28
  /** Report / snapshot group for a budget sample. */
14
- export type BudgetGroup = "core" | "exports" | "drivers";
29
+ export type BudgetGroup = "core" | "exports" | "plugins" | "drivers";
15
30
 
16
31
  /** One published subpath ready for gzip measurement. */
17
32
  export interface ExportBudgetTarget {
18
33
  /** Sample id — `export:<subpath>`. */
19
34
  readonly id: string;
20
- /** Package subpath (`.` or `./store`, `./drivers/postgres`, …). */
35
+ /** Package subpath (`.` or `./store`, `./drivers/postgres`, `./plugins/cors`, …). */
21
36
  readonly subpath: string;
22
37
  /** Short human label (`channel`, `postgres`, `okengine`, …). */
23
38
  readonly label: string;
@@ -25,18 +40,23 @@ export interface ExportBudgetTarget {
25
40
  readonly group: BudgetGroup;
26
41
  /** Absolute path to the TypeScript entry. */
27
42
  readonly entry: string;
43
+ /** Docs category when `group` is `plugins`. */
44
+ readonly category?: PluginBudgetCategory;
28
45
  }
29
46
 
30
47
  /**
31
48
  * Short display name for a package subpath.
32
49
  *
33
- * @param subpath - `.` / `./channel` / `./drivers/postgres`
50
+ * @param subpath - `.` / `./channel` / `./drivers/postgres` / `./plugins/cors`
34
51
  */
35
52
  export function exportBudgetLabel(subpath: string): string {
36
53
  if (subpath === ".") return "okengine";
37
54
  if (subpath.startsWith("./drivers/")) {
38
55
  return subpath.slice("./drivers/".length);
39
56
  }
57
+ if (subpath.startsWith("./plugins/")) {
58
+ return subpath.slice("./plugins/".length);
59
+ }
40
60
  if (subpath.startsWith("./")) return subpath.slice(2);
41
61
  return subpath;
42
62
  }
@@ -50,6 +70,10 @@ export function exportBudgetGroup(subpath: string): BudgetGroup {
50
70
  if (subpath === "./drivers" || subpath.startsWith("./drivers/")) {
51
71
  return "drivers";
52
72
  }
73
+ // Named plugin modules (not the `./plugins` barrel — that stays in exports).
74
+ if (subpath.startsWith("./plugins/")) {
75
+ return "plugins";
76
+ }
53
77
  return "exports";
54
78
  }
55
79
 
@@ -82,6 +106,30 @@ export async function listDriverModules(driversDir = DRIVERS_DIR): Promise<reado
82
106
  return entries.filter(isMeasurableDriverFile).sort((a, b) => a.localeCompare(b));
83
107
  }
84
108
 
109
+ /**
110
+ * Resolve official plugin modules into measurement targets.
111
+ *
112
+ * @param options - Override plugins dir (tests)
113
+ */
114
+ export function resolvePluginBudgetTargets(options?: {
115
+ readonly pluginsDir?: string;
116
+ readonly catalogue?: readonly OfficialPluginBudget[];
117
+ }): readonly ExportBudgetTarget[] {
118
+ const pluginsDir = options?.pluginsDir ?? PLUGINS_DIR;
119
+ const catalogue = options?.catalogue ?? OFFICIAL_PLUGIN_BUDGETS;
120
+ return catalogue.map((plugin) => {
121
+ const subpath = `./plugins/${plugin.name}`;
122
+ return {
123
+ id: `export:${subpath}`,
124
+ subpath,
125
+ label: plugin.name,
126
+ group: "plugins" as const,
127
+ entry: join(pluginsDir, plugin.file),
128
+ category: plugin.category,
129
+ };
130
+ });
131
+ }
132
+
85
133
  /**
86
134
  * Resolve every published export into a stable, sorted measurement target list.
87
135
  *
@@ -91,12 +139,14 @@ export async function resolveExportBudgetTargets(options?: {
91
139
  readonly root?: string;
92
140
  readonly pkg?: PackageExports;
93
141
  readonly driversDir?: string;
142
+ readonly pluginsDir?: string;
94
143
  }): Promise<readonly ExportBudgetTarget[]> {
95
144
  const root = options?.root ?? ROOT;
96
145
  const pkg =
97
146
  options?.pkg ?? ((await Bun.file(join(root, "package.json")).json()) as PackageExports);
98
147
  const exportsMap = pkg.exports ?? {};
99
148
  const driversDir = options?.driversDir ?? join(root, "src/drivers");
149
+ const pluginsDir = options?.pluginsDir ?? join(root, "src/plugins");
100
150
 
101
151
  const targets: ExportBudgetTarget[] = [];
102
152
 
@@ -121,6 +171,8 @@ export async function resolveExportBudgetTargets(options?: {
121
171
  targets.push(targetFor(subpath, resolve(root, entryRel)));
122
172
  }
123
173
 
174
+ targets.push(...resolvePluginBudgetTargets({ pluginsDir }));
175
+
124
176
  targets.sort((a, b) => {
125
177
  const g = groupOrder(a.group) - groupOrder(b.group);
126
178
  if (g !== 0) return g;
@@ -129,6 +181,12 @@ export async function resolveExportBudgetTargets(options?: {
129
181
  if (b.subpath === "./drivers") return 1;
130
182
  if (a.subpath === ".") return -1;
131
183
  if (b.subpath === ".") return 1;
184
+ // Catalogue order for plugins (docs category order).
185
+ if (a.group === "plugins" && b.group === "plugins") {
186
+ const ai = OFFICIAL_PLUGIN_BUDGETS.findIndex((p) => p.name === a.label);
187
+ const bi = OFFICIAL_PLUGIN_BUDGETS.findIndex((p) => p.name === b.label);
188
+ if (ai !== -1 && bi !== -1) return ai - bi;
189
+ }
132
190
  return a.label.localeCompare(b.label);
133
191
  });
134
192
  return targets;
@@ -146,6 +204,7 @@ function targetFor(subpath: string, entry: string): ExportBudgetTarget {
146
204
 
147
205
  function groupOrder(group: BudgetGroup): number {
148
206
  if (group === "exports") return 0;
149
- if (group === "drivers") return 1;
150
- return 2;
207
+ if (group === "plugins") return 1;
208
+ if (group === "drivers") return 2;
209
+ return 3;
151
210
  }
@@ -7,9 +7,14 @@ export {
7
7
  exportBudgetLabel,
8
8
  isMeasurableDriverFile,
9
9
  listDriverModules,
10
+ OFFICIAL_PLUGIN_BUDGETS,
11
+ PLUGIN_BUDGET_CATEGORIES,
10
12
  resolveExportBudgetTargets,
13
+ resolvePluginBudgetTargets,
11
14
  type BudgetGroup,
12
15
  type ExportBudgetTarget,
16
+ type OfficialPluginBudget,
17
+ type PluginBudgetCategory,
13
18
  } from "./exports.ts";
14
19
 
15
20
  export {
@@ -31,7 +31,7 @@ describe("export gzip budgets", () => {
31
31
  );
32
32
  });
33
33
 
34
- test("formatBudgetsReport groups Core / Exports / Drivers with short names", () => {
34
+ test("formatBudgetsReport groups Core / Exports / Plugins / Drivers with short names", () => {
35
35
  const snapshot: BudgetsSnapshot = {
36
36
  measuredAt: "2026-07-25T00:00:00.000Z",
37
37
  version: "0.0.0",
@@ -56,6 +56,16 @@ describe("export gzip budgets", () => {
56
56
  group: "exports",
57
57
  ok: true,
58
58
  },
59
+ {
60
+ id: "export:./plugins/cors",
61
+ label: "cors",
62
+ value: 100,
63
+ limit: 200,
64
+ unit: "bytes",
65
+ gate: "regression",
66
+ group: "plugins",
67
+ ok: true,
68
+ },
59
69
  {
60
70
  id: "export:./drivers/postgres",
61
71
  label: "postgres",
@@ -71,8 +81,10 @@ describe("export gzip budgets", () => {
71
81
  const report = formatBudgetsReport(snapshot);
72
82
  expect(report).toContain("\nCore\n");
73
83
  expect(report).toContain("\nExports\n");
84
+ expect(report).toContain("\nPlugins\n");
74
85
  expect(report).toContain("\nDrivers\n");
75
86
  expect(report).toContain("[ok] channel:");
87
+ expect(report).toContain("[ok] cors:");
76
88
  expect(report).toContain("[ok] postgres:");
77
89
  expect(report).not.toContain("regression Export");
78
90
  expect(report).not.toContain("./channel");
@@ -6,7 +6,12 @@ import { mkdtemp, readdir, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join, resolve } from "node:path";
8
8
  import { createRouter } from "../kernel/router.ts";
9
- import { type BudgetGroup, resolveExportBudgetTargets } from "./exports.ts";
9
+ import {
10
+ type BudgetGroup,
11
+ OFFICIAL_PLUGIN_BUDGETS,
12
+ PLUGIN_BUDGET_CATEGORIES,
13
+ resolveExportBudgetTargets,
14
+ } from "./exports.ts";
10
15
  import {
11
16
  CLIENT_BUDGET_BYTES,
12
17
  COLD_START_BUDGET_MS,
@@ -56,9 +61,18 @@ export type BudgetGate = "absolute" | "regression";
56
61
  const GROUP_HEADINGS: Readonly<Record<BudgetGroup, string>> = {
57
62
  core: "Core",
58
63
  exports: "Exports",
64
+ plugins: "Plugins",
59
65
  drivers: "Drivers",
60
66
  };
61
67
 
68
+ /** Docs category → markdown subheading under Plugins. */
69
+ const PLUGIN_CATEGORY_HEADINGS: Readonly<Record<string, string>> = {
70
+ auth: "Auth",
71
+ security: "Security",
72
+ ops: "Ops",
73
+ perf: "Perf",
74
+ };
75
+
62
76
  /** One measured budget sample. */
63
77
  export interface BudgetSample {
64
78
  /** Stable metric id. */
@@ -489,13 +503,13 @@ export async function measureAllBudgets(): Promise<BudgetsSnapshot> {
489
503
  }
490
504
 
491
505
  /**
492
- * Format a snapshot for CI logs (Core / Exports / Drivers sections).
506
+ * Format a snapshot for CI logs (Core / Exports / Plugins / Drivers).
493
507
  *
494
508
  * @param snapshot - Measured budgets
495
509
  */
496
510
  export function formatBudgetsReport(snapshot: BudgetsSnapshot): string {
497
511
  const lines = [`okengine budgets v${snapshot.version} @ ${snapshot.measuredAt}`];
498
- const order: readonly BudgetGroup[] = ["core", "exports", "drivers"];
512
+ const order: readonly BudgetGroup[] = ["core", "exports", "plugins", "drivers"];
499
513
  for (const group of order) {
500
514
  const rows = snapshot.budgets.filter((b) => b.group === group);
501
515
  if (rows.length === 0) continue;
@@ -511,6 +525,28 @@ export function formatBudgetsReport(snapshot: BudgetsSnapshot): string {
511
525
  return `${lines.join("\n")}\n`;
512
526
  }
513
527
 
528
+ /**
529
+ * Markdown table with column widths padded so `oxfmt` is a no-op.
530
+ * Compact `|---|` tables get realigned by oxfmt and make Format fail after
531
+ * every `bun run budgets` refresh.
532
+ *
533
+ * @param headers - Column headers (use `""` for an unnamed first column)
534
+ * @param rows - Body cells
535
+ */
536
+ export function formatMarkdownTable(
537
+ headers: readonly string[],
538
+ rows: readonly (readonly string[])[],
539
+ ): string[] {
540
+ const widths = headers.map((header, i) =>
541
+ Math.max(header.length, ...rows.map((row) => (row[i] ?? "").length)),
542
+ );
543
+ const cell = (value: string, i: number): string => value.padEnd(widths[i]!);
544
+ const line = (cells: readonly string[]): string =>
545
+ `| ${cells.map((value, i) => cell(value, i)).join(" | ")} |`;
546
+ const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
547
+ return [line(headers), sep, ...rows.map((row) => line(row))];
548
+ }
549
+
514
550
  /**
515
551
  * Full markdown document for [`BUDGETS.md`](../../BUDGETS.md).
516
552
  * Written by `bun run budgets` — do not edit the tables by hand.
@@ -525,24 +561,51 @@ export function formatBudgetsMarkdown(snapshot: BudgetsSnapshot): string {
525
561
  "",
526
562
  `_okengine v${snapshot.version} · measured ${snapshot.measuredAt}_`,
527
563
  "",
528
- "Core rows are absolute AGENTS caps. Exports and Drivers fail on regression vs the prior [`budgets.json`](budgets.json) (max +256 B or +2%). Export gzip excludes peers/optionals (`zod`, `sently`, `oxc-parser`, `ajv`).",
564
+ "Core rows are absolute AGENTS caps. Exports, Plugins, and Drivers fail on regression vs the prior [`budgets.json`](budgets.json) (max +256 B or +2%). Export gzip excludes peers/optionals (`zod`, `sently`, `oxc-parser`, `ajv`).",
529
565
  ];
530
- const order: readonly BudgetGroup[] = ["core", "exports", "drivers"];
566
+ const order: readonly BudgetGroup[] = ["core", "exports", "plugins", "drivers"];
531
567
  for (const group of order) {
532
568
  const rows = snapshot.budgets.filter((b) => b.group === group);
533
569
  if (rows.length === 0) continue;
534
570
  const limitCol = group === "core" ? "Limit" : "Ceiling";
535
571
  lines.push("");
536
572
  lines.push(`## ${GROUP_HEADINGS[group]}`);
537
- lines.push("");
538
- lines.push(`| | Measured | ${limitCol} |`);
539
- lines.push("|---|---|---|");
540
- for (const b of rows) {
541
- const mark = b.ok ? "" : " **FAIL**";
542
- lines.push(
543
- `| ${b.label}${mark} | ${formatValue(b.value, b.unit)} | ${formatValue(b.limit, b.unit)} |`,
544
- );
573
+ if (group === "plugins") {
574
+ for (const category of PLUGIN_BUDGET_CATEGORIES) {
575
+ const names = new Set(
576
+ OFFICIAL_PLUGIN_BUDGETS.filter((p) => p.category === category).map((p) => p.name),
577
+ );
578
+ const catRows = rows.filter((b) => names.has(b.label));
579
+ if (catRows.length === 0) continue;
580
+ lines.push("");
581
+ lines.push(`### ${PLUGIN_CATEGORY_HEADINGS[category] ?? category}`);
582
+ lines.push("");
583
+ lines.push(
584
+ ...formatMarkdownTable(
585
+ ["", "Measured", limitCol],
586
+ catRows.map((b) => {
587
+ const mark = b.ok ? "" : " **FAIL**";
588
+ return [
589
+ `${b.label}${mark}`,
590
+ formatValue(b.value, b.unit),
591
+ formatValue(b.limit, b.unit),
592
+ ];
593
+ }),
594
+ ),
595
+ );
596
+ }
597
+ continue;
545
598
  }
599
+ lines.push("");
600
+ lines.push(
601
+ ...formatMarkdownTable(
602
+ ["", "Measured", limitCol],
603
+ rows.map((b) => {
604
+ const mark = b.ok ? "" : " **FAIL**";
605
+ return [`${b.label}${mark}`, formatValue(b.value, b.unit), formatValue(b.limit, b.unit)];
606
+ }),
607
+ ),
608
+ );
546
609
  }
547
610
  return `${lines.join("\n")}\n`;
548
611
  }