shraga 0.1.3 → 0.1.5
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.
- package/README.md +115 -3
- package/defaults/extensions/stripe-webhook.ext.ts +29 -21
- package/dist/client/assets/{index-BoHttkMt.js → index-BnArwb7g.js} +43 -43
- package/dist/client/index.html +1 -1
- package/package.json +7 -4
- package/src/cli.ts +3 -1
- package/src/client/components/ArtifactPanel.tsx +16 -3
- package/src/index.ts +180 -0
- package/src/server/artifacts/artifacts.routes.ts +2 -19
- package/src/server/boot.ts +1815 -0
- package/src/server/events/bus.ts +29 -5
- package/src/server/events/types.ts +26 -3
- package/src/server/events/webhook.ts +64 -0
- package/src/server/extensions.ts +48 -0
- package/src/server/index.ts +8 -1712
- package/src/server/spa-catchall.ts +41 -0
- package/src/server/artifacts/artifacts.export.ts +0 -85
package/src/server/events/bus.ts
CHANGED
|
@@ -4,24 +4,48 @@
|
|
|
4
4
|
// call emitEvent(); the dispatcher subscribes and routes matching events to the
|
|
5
5
|
// scheduler's execution path. Deliberately minimal: no plugin registry, no
|
|
6
6
|
// lifecycle — just emit + subscribe. Adding a source = one emitEvent() call.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// TYPING: `ShragaEventMap` (types.ts) maps a source → its payload type. A source
|
|
9
|
+
// listed there gets a checked payload; anything else falls back to `unknown`, so
|
|
10
|
+
// ad-hoc emitters keep compiling. Add-ons register their source via declaration
|
|
11
|
+
// merging (see types.ts).
|
|
12
|
+
import type { ShragaEvent, PayloadOf } from './types.ts';
|
|
8
13
|
|
|
9
14
|
type Listener = (evt: ShragaEvent) => void;
|
|
10
15
|
|
|
11
16
|
const listeners = new Set<Listener>();
|
|
12
17
|
|
|
13
|
-
/**
|
|
18
|
+
/** Clear all subscribers. For tests: `listeners` is a process-global, so a test file that boots a
|
|
19
|
+
* server (or its on() subscribers) leaks listeners into a later file's emit. Reset between files
|
|
20
|
+
* that share bus state to stay hermetic — bun's cross-file order is not stable across platforms.
|
|
21
|
+
* Mirrors clearTurnContext()/`__resetExtensionsForTest()`. Test-only. */
|
|
22
|
+
export function __resetEventBusForTest(): void {
|
|
23
|
+
listeners.clear();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Subscribe to ALL events. Returns an unsubscribe fn. The dispatcher uses this. */
|
|
14
27
|
export function subscribeEvents(fn: Listener): () => void {
|
|
15
28
|
listeners.add(fn);
|
|
16
29
|
return () => listeners.delete(fn);
|
|
17
30
|
}
|
|
18
31
|
|
|
32
|
+
/** Subscribe to a SINGLE source with a typed payload. Thin filter over
|
|
33
|
+
* subscribeEvents — convenience + typing for the common "I only care about X" case. */
|
|
34
|
+
export function subscribeEvent<K extends string>(
|
|
35
|
+
source: K,
|
|
36
|
+
handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void,
|
|
37
|
+
): () => void {
|
|
38
|
+
return subscribeEvents((evt) => {
|
|
39
|
+
if (evt.source === source) handler(evt.payload as PayloadOf<K>, evt as ShragaEvent<K>);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
/** Publish an event. Listeners are invoked synchronously; a throwing listener is
|
|
20
44
|
* logged and skipped so one bad subscriber can't sink the emit. */
|
|
21
|
-
export function emitEvent(source:
|
|
22
|
-
const evt: ShragaEvent = { source, payload, id: opts?.id, at: Date.now() };
|
|
45
|
+
export function emitEvent<K extends string>(source: K, payload: PayloadOf<K>, opts?: { id?: string }): ShragaEvent<K> {
|
|
46
|
+
const evt: ShragaEvent<K> = { source, payload, id: opts?.id, at: Date.now() };
|
|
23
47
|
for (const fn of listeners) {
|
|
24
|
-
try { fn(evt); } catch (err) {
|
|
48
|
+
try { fn(evt as ShragaEvent); } catch (err) {
|
|
25
49
|
console.error(`[events] listener threw for source "${source}":`, err);
|
|
26
50
|
}
|
|
27
51
|
}
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
/** A single event flowing through the bus. `source` routes it to matching
|
|
2
2
|
* `event`-trigger schedules; `payload` is matched against their `match` filter and
|
|
3
3
|
* injected into the run. `id` (optional) dedupes retried webhook deliveries. */
|
|
4
|
-
export interface ShragaEvent {
|
|
5
|
-
source:
|
|
6
|
-
payload:
|
|
4
|
+
export interface ShragaEvent<K extends string = string> {
|
|
5
|
+
source: K;
|
|
6
|
+
payload: PayloadOf<K>;
|
|
7
7
|
id?: string;
|
|
8
8
|
at: number;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
/** Extensible registry of source → payload types. Add-ons augment it via
|
|
12
|
+
* declaration merging so a known source gets a typed payload, e.g.:
|
|
13
|
+
*
|
|
14
|
+
* declare module '../events/types.ts' {
|
|
15
|
+
* interface ShragaEventMap { stripe: Stripe.Event }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Sources NOT in the map still work — they fall back to `unknown` (see PayloadOf),
|
|
19
|
+
* so untyped/ad-hoc emitters keep compiling. */
|
|
20
|
+
export interface ShragaEventMap {
|
|
21
|
+
// Core sources shipped by shraga itself. Add-ons merge in their own.
|
|
22
|
+
'schedule.finished': {
|
|
23
|
+
scheduleId: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Payload type for a source: the mapped type if registered, else `unknown`. */
|
|
32
|
+
export type PayloadOf<K extends string> = K extends keyof ShragaEventMap ? ShragaEventMap[K] : unknown;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Generic verified-webhook → event bridge.
|
|
2
|
+
//
|
|
3
|
+
// A vendor webhook can't present shraga auth (it carries the vendor's OWN
|
|
4
|
+
// signature), so it can't hit the auth-gated POST /api/events/:source. Instead
|
|
5
|
+
// declare it as data: registerWebhook(app, { source, verify }) mounts a PUBLIC
|
|
6
|
+
// POST /api/webhooks/<source>, and on a passing `verify` calls emitEvent(source).
|
|
7
|
+
//
|
|
8
|
+
// The ONLY per-vendor piece is `verify` — dispatch, dedup, and typing are generic.
|
|
9
|
+
// The raw request bytes (needed for HMAC) are read from req.rawBody, which the
|
|
10
|
+
// global express.json({ verify }) in index.ts stashes for every request.
|
|
11
|
+
import type { IRouter, Request, Response } from 'express';
|
|
12
|
+
import { emitEvent } from './bus.ts';
|
|
13
|
+
import type { PayloadOf } from './types.ts';
|
|
14
|
+
|
|
15
|
+
export interface WebhookOptions<K extends string> {
|
|
16
|
+
/** Event source name → the bus source + `/api/webhooks/<source>` path. */
|
|
17
|
+
source: K;
|
|
18
|
+
/** Verify the vendor's signature over the RAW body. Return any FALSY value
|
|
19
|
+
* (false/undefined/null/'') to reject (400). Return `true` to accept
|
|
20
|
+
* (payload = normalize?.(req) ?? parsed body), or return the payload object
|
|
21
|
+
* directly. Receives the raw bytes for HMAC. */
|
|
22
|
+
verify: (req: Request, raw: string) => boolean | PayloadOf<K>;
|
|
23
|
+
/** Optional payload shaper when `verify` returns `true` (defaults to req.body). */
|
|
24
|
+
normalize?: (req: Request) => PayloadOf<K>;
|
|
25
|
+
/** Optional dedup id extractor (retried deliveries). Defaults to body.id. */
|
|
26
|
+
eventId?: (payload: PayloadOf<K>, req: Request) => string | undefined;
|
|
27
|
+
/** Override the mount path (default `/api/webhooks/<source>`). */
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rawBodyOf(req: Request): string {
|
|
32
|
+
const buf = (req as unknown as { rawBody?: unknown }).rawBody;
|
|
33
|
+
return buf instanceof Buffer ? buf.toString('utf8') : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mount a public webhook route that verifies the vendor's signature, then emits a
|
|
37
|
+
* typed event. Returns the mounted path. */
|
|
38
|
+
export function registerWebhook<K extends string>(router: IRouter, opts: WebhookOptions<K>): string {
|
|
39
|
+
const path = opts.path ?? `/api/webhooks/${opts.source}`;
|
|
40
|
+
router.post(path, (req: Request, res: Response) => {
|
|
41
|
+
const raw = rawBodyOf(req);
|
|
42
|
+
let result: boolean | PayloadOf<K>;
|
|
43
|
+
try {
|
|
44
|
+
result = opts.verify(req, raw);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[webhook] verify threw for "${opts.source}":`, err);
|
|
47
|
+
return res.status(400).json({ error: 'verification failed' });
|
|
48
|
+
}
|
|
49
|
+
// Reject on ANY falsy result — a `verify` written as "return the payload, or
|
|
50
|
+
// nothing on failure" yields undefined/null/''/0 on a bad signature; those must
|
|
51
|
+
// NOT fall through and emit an unauthenticated event.
|
|
52
|
+
if (!result) return res.status(400).json({ error: 'bad signature' });
|
|
53
|
+
|
|
54
|
+
const payload: PayloadOf<K> = result === true ? (opts.normalize?.(req) ?? (req.body as PayloadOf<K>)) : result;
|
|
55
|
+
const id = opts.eventId
|
|
56
|
+
? opts.eventId(payload, req)
|
|
57
|
+
: ((payload as { id?: unknown } | null)?.id != null ? String((payload as { id?: unknown }).id) : undefined);
|
|
58
|
+
|
|
59
|
+
emitEvent(opts.source, payload, { id });
|
|
60
|
+
res.json({ received: true });
|
|
61
|
+
});
|
|
62
|
+
console.log(`[webhook] mounted POST ${path} → emitEvent("${opts.source}")`);
|
|
63
|
+
return path;
|
|
64
|
+
}
|
package/src/server/extensions.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
22
22
|
import { dataPath } from './paths.ts';
|
|
23
23
|
import { requireAuth } from './auth.ts';
|
|
24
24
|
import { emitEvent } from './events/bus.ts';
|
|
25
|
+
import { registerWebhook, type WebhookOptions } from './events/webhook.ts';
|
|
25
26
|
|
|
26
27
|
export interface ExtensionContext {
|
|
27
28
|
/** Resolve a path inside the active data dir (e.g. ctx.dataPath('github-app.json')). */
|
|
@@ -35,11 +36,52 @@ export interface ExtensionContext {
|
|
|
35
36
|
/** Publish an event onto the bus → fires matching `event`-trigger schedules.
|
|
36
37
|
* Use after verifying a vendor webhook's own signature. */
|
|
37
38
|
emitEvent: (source: string, payload: unknown, opts?: { id?: string }) => void;
|
|
39
|
+
/** Declare a vendor webhook as data: mounts a PUBLIC POST /api/webhooks/<source>
|
|
40
|
+
* that runs `verify` (the only per-vendor piece) → emitEvent. Returns the path. */
|
|
41
|
+
registerWebhook: <K extends string>(opts: WebhookOptions<K>) => string;
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** A programmatic extension: the same shape as a `*.ext.ts` file's default export. */
|
|
45
|
+
export type ExtRegisterFn = (router: ExpressRouter, ctx: ExtensionContext) => void | Promise<void>;
|
|
46
|
+
|
|
40
47
|
const loaded = new Set<string>();
|
|
41
48
|
let extRouter: ExpressRouter | null = null;
|
|
42
49
|
let ctx: ExtensionContext | null = null;
|
|
50
|
+
const pendingProgrammatic: ExtRegisterFn[] = [];
|
|
51
|
+
|
|
52
|
+
/** Reset the module-global router/ctx/registry. For tests: `extRouter`, `ctx` and `loaded` are
|
|
53
|
+
* process-globals, so a test file that boots a second createShraga server in the same bun process
|
|
54
|
+
* inherits the PRIOR file's router — and a pre-start `registerExtension`/`registerWebhook` (which
|
|
55
|
+
* mounts immediately once `extRouter && ctx` are set, see registerExtension) then lands on the DEAD
|
|
56
|
+
* router instead of queueing for the new one → the new server 404s. bun's cross-file order is not
|
|
57
|
+
* stable across platforms (a Linux CI order that macOS never hits), so a test that boots a server
|
|
58
|
+
* must reset first to be hermetic. Mirrors clearTurnContext() in turn-context.ts. Test-only. */
|
|
59
|
+
export function __resetExtensionsForTest(): void {
|
|
60
|
+
extRouter = null;
|
|
61
|
+
ctx = null;
|
|
62
|
+
loaded.clear();
|
|
63
|
+
pendingProgrammatic.length = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runProgrammatic(fn: ExtRegisterFn): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
await fn(extRouter!, ctx!);
|
|
69
|
+
console.log('[extensions] mounted programmatic extension');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error('[extensions] programmatic register failed:', (err as Error)?.stack || err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Programmatic equivalent of dropping a `data/extensions/*.ext.ts` file. Funnels through the SAME
|
|
77
|
+
* persistent Router + ExtensionContext (mounted before the SPA catch-all), so a webhook/callback
|
|
78
|
+
* registered this way is reachable exactly like a file-based one. If called before loadExtensions()
|
|
79
|
+
* (the usual case — createShraga queues these pre-boot), it runs when the router is ready.
|
|
80
|
+
*/
|
|
81
|
+
export async function registerExtension(fn: ExtRegisterFn): Promise<void> {
|
|
82
|
+
if (extRouter && ctx) return runProgrammatic(fn);
|
|
83
|
+
pendingProgrammatic.push(fn);
|
|
84
|
+
}
|
|
43
85
|
|
|
44
86
|
async function mountFile(dir: string, f: string): Promise<void> {
|
|
45
87
|
if (loaded.has(f) || !extRouter || !ctx) return;
|
|
@@ -77,8 +119,14 @@ export async function loadExtensions(app: Express): Promise<void> {
|
|
|
77
119
|
log: (...a) => console.log('[extensions]', ...a),
|
|
78
120
|
app,
|
|
79
121
|
emitEvent,
|
|
122
|
+
// Mount on the extension Router (before the SPA catch-all) so hot-loaded
|
|
123
|
+
// webhooks are reachable too — never on `app` directly.
|
|
124
|
+
registerWebhook: (opts) => registerWebhook(extRouter!, opts),
|
|
80
125
|
};
|
|
81
126
|
|
|
127
|
+
// Flush programmatic extensions first (they mount ahead of file-based drop-ins, before the catch-all).
|
|
128
|
+
for (const fn of pendingProgrammatic.splice(0)) await runProgrammatic(fn);
|
|
129
|
+
|
|
82
130
|
const dir = path.resolve(dataPath('extensions'));
|
|
83
131
|
await scan(dir);
|
|
84
132
|
|