shraga 0.1.4 → 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 +52 -2
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/server/boot.ts +39 -3
- package/src/server/events/bus.ts +8 -0
- package/src/server/extensions.ts +14 -0
- package/src/server/spa-catchall.ts +41 -0
package/README.md
CHANGED
|
@@ -86,9 +86,59 @@ The core is deliberately small and grows through documented seams (see
|
|
|
86
86
|
| **Route extensions** (`data/extensions/*.ext.ts`) | Drop-in public routes (webhooks, OAuth callbacks) per deployment. |
|
|
87
87
|
| **`SHRAGA_OVERLAY`** | Load a whole external add-on module at startup (the overlay tier above). |
|
|
88
88
|
|
|
89
|
-
##
|
|
89
|
+
## Use as a library
|
|
90
|
+
|
|
91
|
+
Shraga's first-class surface is the `createShraga` factory (the package `main`/`exports`). You
|
|
92
|
+
`import` it, wire your registrations against the same seams the built-ins use, then own the
|
|
93
|
+
lifecycle — `start()` returns a handle you can `stop()`. The CLI and the run-from-source entry are
|
|
94
|
+
thin wrappers over this exact call (`createShraga(fromEnv()).start()`).
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { createShraga } from 'shraga';
|
|
98
|
+
|
|
99
|
+
const shraga = createShraga({
|
|
100
|
+
port: 3032,
|
|
101
|
+
dataDir: './data',
|
|
102
|
+
authProvider: 'local', // 'local' (default) | 'firebase'
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Pre-start registration (chainable). See "The extension seams" in AGENTS.md.
|
|
106
|
+
shraga
|
|
107
|
+
.registerFeature(myFeature) // server routes/WS/consumers
|
|
108
|
+
.registerEngine(myEngine) // a pluggable agent runtime
|
|
109
|
+
.registerExtension(register) // same shape as a data/extensions/*.ext.ts default export
|
|
110
|
+
.registerWebhook({ source: 'stripe', verify }) // public POST /api/webhooks/stripe → typed event
|
|
111
|
+
.on('stripe', (payload, evt) => { /* handle the event */ });
|
|
112
|
+
|
|
113
|
+
const handle = await shraga.start();
|
|
114
|
+
console.log(`listening on ${handle.url}`); // { app, server, port, url, emitEvent, on, ... }
|
|
115
|
+
|
|
116
|
+
// later:
|
|
117
|
+
await handle.stop(); // drains and closes without exiting the process
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`ShragaOptions` is typed; anything not modelled is still reachable via `env` (Shraga is heavily
|
|
121
|
+
env-driven), applied before boot:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
createShraga({ env: { ANTHROPIC_API_KEY: '…', SHRAGA_FEAT_WORKSPACE: '1' } });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**`ServerHandle`** (returned by `start()`): `{ app, server, port, url, emitEvent, on,
|
|
128
|
+
registerExtension, registerWebhook, stop }`.
|
|
129
|
+
|
|
130
|
+
**Runtime plug-and-play is opt-in.** `registerFeature`/`registerEngine`/`registerExtension`/
|
|
131
|
+
`registerWebhook`/`on` are meant to run **before** `start()`. If you need to register *after* boot,
|
|
132
|
+
set `runtimeRegistration: true` (or `SHRAGA_RUNTIME_REGISTRATION=1`) — then the `ServerHandle`'s
|
|
133
|
+
`registerExtension`/`registerWebhook`/`on` mount onto the live extension Router / event bus.
|
|
134
|
+
Default is **off**, and those handle methods throw until you enable it. Only extensions, webhooks
|
|
135
|
+
and event subscriptions are runtime-registerable — features and engines mount at boot and are never
|
|
136
|
+
runtime-registerable, on or off.
|
|
137
|
+
|
|
138
|
+
## Quickstart (CLI)
|
|
90
139
|
|
|
91
|
-
Requires [Bun](https://bun.sh) ≥ 1.0.
|
|
140
|
+
Requires [Bun](https://bun.sh) ≥ 1.0. Env-configured, no code — this is the `createShraga(fromEnv())`
|
|
141
|
+
tier.
|
|
92
142
|
|
|
93
143
|
**Fastest — run from npm:**
|
|
94
144
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -56,6 +56,11 @@ export class ShragaOptions {
|
|
|
56
56
|
/** Install process SIGTERM/SIGINT handlers (default true — the standalone server/CLI wants them).
|
|
57
57
|
* A library embedder owning its own lifecycle sets false and uses stop(). */
|
|
58
58
|
installSignalHandlers?: boolean = true;
|
|
59
|
+
/** OPT-IN runtime plug-and-play (default false). When true, the ServerHandle returned by start()
|
|
60
|
+
* can register extensions/webhooks and subscribe to events AFTER boot (they mount on the live
|
|
61
|
+
* extension Router / event bus). When false, those handle methods throw. Pre-start registration is
|
|
62
|
+
* unaffected either way. Features & engines are NEVER runtime-registerable (they mount at boot). */
|
|
63
|
+
runtimeRegistration?: boolean = false;
|
|
59
64
|
/** Arbitrary extra environment to apply before boot (e.g. ANTHROPIC_API_KEY, SHRAGA_FEAT_*). */
|
|
60
65
|
env?: Record<string, string>;
|
|
61
66
|
}
|
|
@@ -103,6 +108,7 @@ class Shraga implements ShragaInstance {
|
|
|
103
108
|
if (o.authProvider != null) process.env.AUTH_PROVIDER = o.authProvider;
|
|
104
109
|
if (o.passive != null) process.env.SHRAGA_PASSIVE = o.passive ? '1' : '0';
|
|
105
110
|
if (o.installSignalHandlers === false) process.env.SHRAGA_INSTALL_SIGNALS = '0';
|
|
111
|
+
if (o.runtimeRegistration != null) process.env.SHRAGA_RUNTIME_REGISTRATION = o.runtimeRegistration ? '1' : '0';
|
|
106
112
|
for (const [k, v] of Object.entries(o.env ?? {})) process.env[k] = v;
|
|
107
113
|
}
|
|
108
114
|
|
|
@@ -163,10 +169,12 @@ export function createShraga(options?: Partial<ShragaOptions>): ShragaInstance {
|
|
|
163
169
|
* run-from-source entry and the CLI pass this straight into createShraga. */
|
|
164
170
|
export function fromEnv(): Partial<ShragaOptions> {
|
|
165
171
|
const passive = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
|
|
172
|
+
const runtimeReg = process.env.SHRAGA_RUNTIME_REGISTRATION;
|
|
166
173
|
return {
|
|
167
174
|
port: process.env.PORT ? Number(process.env.PORT) : undefined,
|
|
168
175
|
dataDir: process.env.DATA_DIR || undefined,
|
|
169
176
|
authProvider: (process.env.AUTH_PROVIDER as 'local' | 'firebase' | undefined) || undefined,
|
|
170
177
|
passive: passive === '1' || passive === 'true' ? true : undefined,
|
|
178
|
+
runtimeRegistration: runtimeReg === '1' || runtimeReg === 'true' ? true : undefined,
|
|
171
179
|
};
|
|
172
180
|
}
|
package/src/server/boot.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { requireAuth, verifyBearer, AUTH_PROVIDER, localLogin, addLocalUser, loc
|
|
|
22
22
|
import { getMcpConfig, getRawMcpConfig, getResolvedMcpConfig, getGlobalMcpConfig, saveMcpConfig, maskEnvValues, mergeWithOriginal, type McpConfig } from './mcp.ts';
|
|
23
23
|
import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
|
|
24
24
|
import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
|
|
25
|
+
import { registerSpaCatchAll } from './spa-catchall.ts';
|
|
25
26
|
import { slackFeature } from './slack/feature.ts';
|
|
26
27
|
import { dataPath } from './paths.ts';
|
|
27
28
|
import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, resetRetryCount, getRunningSessions, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
|
|
@@ -62,6 +63,8 @@ import type { Server as HttpServer } from 'node:http';
|
|
|
62
63
|
import type { Express } from 'express';
|
|
63
64
|
import { emitEvent } from './events/bus.ts';
|
|
64
65
|
import type { ExtRegisterFn } from './extensions.ts';
|
|
66
|
+
import type { WebhookOptions } from './events/webhook.ts';
|
|
67
|
+
import type { ShragaEvent, PayloadOf } from './events/types.ts';
|
|
65
68
|
|
|
66
69
|
export interface BootRegistrations {
|
|
67
70
|
features?: ServerFeature[];
|
|
@@ -77,6 +80,16 @@ export interface ServerHandle {
|
|
|
77
80
|
url: string;
|
|
78
81
|
/** Publish an event onto the in-process bus (same fn extensions get as ctx.emitEvent). */
|
|
79
82
|
emitEvent: typeof emitEvent;
|
|
83
|
+
/** Register an extension AFTER start() — mounts onto the live extension Router (before the SPA
|
|
84
|
+
* catch-all), the same seam file-based *.ext.ts drop-ins hot-load through. OPT-IN: throws unless
|
|
85
|
+
* ShragaOptions.runtimeRegistration is enabled. */
|
|
86
|
+
registerExtension: (fn: ExtRegisterFn) => Promise<void>;
|
|
87
|
+
/** Declare a verified vendor webhook AFTER start() (sugar over registerExtension — a webhook IS an
|
|
88
|
+
* extension). OPT-IN: throws unless runtimeRegistration is enabled. */
|
|
89
|
+
registerWebhook: <K extends string>(opts: WebhookOptions<K>) => Promise<void>;
|
|
90
|
+
/** Subscribe to a typed event source AFTER start(). Returns an unsubscribe fn. OPT-IN: throws
|
|
91
|
+
* unless runtimeRegistration is enabled. */
|
|
92
|
+
on: <K extends string>(source: K, handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void) => () => void;
|
|
80
93
|
/** Drain in-flight streams, stop consumers, close the server. Does NOT exit the process. */
|
|
81
94
|
stop: () => Promise<void>;
|
|
82
95
|
}
|
|
@@ -723,9 +736,12 @@ await loadExtensions(app);
|
|
|
723
736
|
|
|
724
737
|
if (existsSync(distPath)) app.use(express.static(distPath));
|
|
725
738
|
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
739
|
+
// The SPA catch-all (`app.get('*')`) is registered LATER — after mountFeatures() — via
|
|
740
|
+
// registerSpaCatchAll(), so feature/extension GET routes are matched before falling through to
|
|
741
|
+
// index.html. It is re-registered on passive→active promotion (mountFeatures runs again there),
|
|
742
|
+
// each call splicing out the prior catch-all layer so it stays truly LAST in the router stack.
|
|
743
|
+
// In dev (no dist/) it is skipped entirely — Vite serves the SPA and a catch-all would 404-shadow it.
|
|
744
|
+
// Implementation + predicate live in ./spa-catchall.ts (unit-tested there).
|
|
729
745
|
|
|
730
746
|
// ── WebSocket + Server ───────────────────────────────────────────────────────
|
|
731
747
|
|
|
@@ -946,6 +962,9 @@ mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
|
|
|
946
962
|
// Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
|
|
947
963
|
Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
|
|
948
964
|
|
|
965
|
+
// SPA fallback — MUST be the last GET route so it never shadows real API/feature/extension routes.
|
|
966
|
+
registerSpaCatchAll(app, distPath);
|
|
967
|
+
|
|
949
968
|
// ── Runtime promotion (blue-green flip) ──────────────────────────────────────
|
|
950
969
|
// A passive instance can be promoted to active once traffic has been flipped to it:
|
|
951
970
|
// starts every consumer/writer that passive boot skipped. One-way; idempotent-guarded.
|
|
@@ -958,6 +977,8 @@ async function activateConsumers() {
|
|
|
958
977
|
scheduler.start(broadcast);
|
|
959
978
|
startEventDispatcher();
|
|
960
979
|
mountFeatures({ app, requireAuth, broadcast, passive: false });
|
|
980
|
+
// Re-place the SPA catch-all AFTER the promotion's feature mount so newly-added GET routes win.
|
|
981
|
+
registerSpaCatchAll(app, distPath);
|
|
961
982
|
startSidecars().catch(err => console.error('[sidecar] startup error:', err));
|
|
962
983
|
recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
|
|
963
984
|
}
|
|
@@ -1767,12 +1788,27 @@ await new Promise<void>((resolve) => {
|
|
|
1767
1788
|
});
|
|
1768
1789
|
});
|
|
1769
1790
|
|
|
1791
|
+
// OPT-IN post-start plug-and-play. Only extensions/webhooks/events are runtime-safe (they mount on
|
|
1792
|
+
// the persistent extRouter / in-process bus); features & engines mount at boot and are NOT re-entrant.
|
|
1793
|
+
const RT_FLAG = process.env.SHRAGA_RUNTIME_REGISTRATION;
|
|
1794
|
+
const RUNTIME_REG = RT_FLAG === '1' || RT_FLAG === 'true';
|
|
1795
|
+
const runtimeGuard = (what: string) => {
|
|
1796
|
+
if (!RUNTIME_REG) throw new Error(
|
|
1797
|
+
`[shraga] ${what}() at runtime is disabled. Enable it with createShraga({ runtimeRegistration: true }) ` +
|
|
1798
|
+
`(or SHRAGA_RUNTIME_REGISTRATION=1) before start().`,
|
|
1799
|
+
);
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1770
1802
|
return {
|
|
1771
1803
|
app,
|
|
1772
1804
|
server,
|
|
1773
1805
|
port: PORT,
|
|
1774
1806
|
url: `http://localhost:${PORT}`,
|
|
1775
1807
|
emitEvent,
|
|
1808
|
+
registerExtension: (fn) => { runtimeGuard('registerExtension'); return registerExtension(fn); },
|
|
1809
|
+
// A webhook is just an extension that mounts a verified route on the extension Router — reuse the seam.
|
|
1810
|
+
registerWebhook: (opts) => { runtimeGuard('registerWebhook'); return registerExtension((_r, ctx) => { ctx.registerWebhook(opts); }); },
|
|
1811
|
+
on: (source, handler) => { runtimeGuard('on'); return subscribeEvent(source, handler); },
|
|
1776
1812
|
stop: () => gracefulShutdown('stop', { exit: false }),
|
|
1777
1813
|
};
|
|
1778
1814
|
|
package/src/server/events/bus.ts
CHANGED
|
@@ -15,6 +15,14 @@ type Listener = (evt: ShragaEvent) => void;
|
|
|
15
15
|
|
|
16
16
|
const listeners = new Set<Listener>();
|
|
17
17
|
|
|
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
|
+
|
|
18
26
|
/** Subscribe to ALL events. Returns an unsubscribe fn. The dispatcher uses this. */
|
|
19
27
|
export function subscribeEvents(fn: Listener): () => void {
|
|
20
28
|
listeners.add(fn);
|
package/src/server/extensions.ts
CHANGED
|
@@ -49,6 +49,20 @@ let extRouter: ExpressRouter | null = null;
|
|
|
49
49
|
let ctx: ExtensionContext | null = null;
|
|
50
50
|
const pendingProgrammatic: ExtRegisterFn[] = [];
|
|
51
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
|
+
|
|
52
66
|
async function runProgrammatic(fn: ExtRegisterFn): Promise<void> {
|
|
53
67
|
try {
|
|
54
68
|
await fn(extRouter!, ctx!);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import type express from 'express';
|
|
4
|
+
|
|
5
|
+
// Non-page prefixes that must fall through to a real 404 (JSON/API/transport), never the SPA shell.
|
|
6
|
+
function isNonPagePath(p: string): boolean {
|
|
7
|
+
return p.startsWith('/api/') || p.startsWith('/mcp') || p.startsWith('/uploads') ||
|
|
8
|
+
p.startsWith('/internal/') || p.startsWith('/.well-known');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the SPA catch-all (`app.get('*')`) as the LAST GET route so it serves the built
|
|
13
|
+
* index.html for client routes/deep links (/oauth/authorize, /cli-auth, /session/x, …) while
|
|
14
|
+
* letting unmatched API/MCP/upload/internal/oauth-metadata paths fall through to a real 404.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent + re-placeable: each call splices out any prior catch-all layer, so it can be called
|
|
17
|
+
* again after a later mountFeatures() (passive→active promotion) and still sit behind those routes.
|
|
18
|
+
* In dev (no built dist) it is a no-op — Vite serves the SPA and a catch-all would 404-shadow it.
|
|
19
|
+
*
|
|
20
|
+
* EXPRESS 4 COUPLING: uses the bare `app.get('*')` route and Express's private `_router.stack`.
|
|
21
|
+
* A future express@5 bump breaks this LOUDLY (path-to-regexp v8 rejects `'*'`), not silently —
|
|
22
|
+
* re-verify the promotion-path ordering if express is ever upgraded.
|
|
23
|
+
*/
|
|
24
|
+
export function registerSpaCatchAll(app: express.Express, distPath: string): void {
|
|
25
|
+
if (!existsSync(distPath)) return;
|
|
26
|
+
const indexHtml = path.join(distPath, 'index.html');
|
|
27
|
+
// For app.get(), the layer's `handle` is the Route's dispatcher — our tagged handler lives inside
|
|
28
|
+
// `layer.route.stack[*].handle`. Match on that so re-registration removes the stale catch-all.
|
|
29
|
+
const stack = (app as any)._router?.stack as Array<{ route?: { stack?: Array<{ handle?: { __spaCatchAll?: boolean } }> } }> | undefined;
|
|
30
|
+
if (stack) {
|
|
31
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
32
|
+
if (stack[i]?.route?.stack?.some((h) => h?.handle?.__spaCatchAll)) stack.splice(i, 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const handler: express.RequestHandler = (req, res, next) => {
|
|
36
|
+
if (isNonPagePath(req.path)) return next();
|
|
37
|
+
res.sendFile(indexHtml);
|
|
38
|
+
};
|
|
39
|
+
(handler as { __spaCatchAll?: boolean }).__spaCatchAll = true;
|
|
40
|
+
app.get('*', handler);
|
|
41
|
+
}
|