okengine 0.21.0 → 0.21.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.
- package/package.json +2 -7
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/client/create.ts +13 -26
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.ts +28 -73
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/interpret.ts +45 -0
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Bgt9bq2r.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-CSKumwS2.js → agent-disclosure-B43CXZZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-92uM5MO7.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-DtGVPtcs.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-DN7l6zmC.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CwoV56jn.js → duration-tone-BmIR9FV8.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-BMHs-IzK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-BlAEVgNW.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-BDf7OAHv.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-Cul17AcV.js → index-BKpaes3n.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-oY9vdYBk.js → observability-page-WnVLI-0j.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-B3GLNVfF.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-DMbnAe3f.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CL0D9dOq.js → store-page-KvFDingJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Htk8Cm9t.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CFMPWX4f.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-1PlT19ft.js → units-page-C4NdNuxP.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-CBYl0LW_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/elements/store/sql-session.ts +20 -4
- package/src/kernel/app.ts +9 -2
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +23 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +37 -329
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/release/build-lib.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -141,7 +141,6 @@
|
|
|
141
141
|
},
|
|
142
142
|
"dependencies": {
|
|
143
143
|
"@clack/prompts": "^1.8.0",
|
|
144
|
-
"@tanstack/react-virtual": "^3.14.11",
|
|
145
144
|
"intl-messageformat": "^11.2.14",
|
|
146
145
|
"oxc-parser": "^0.149.0",
|
|
147
146
|
"sently": "1.2.2"
|
|
@@ -169,6 +168,7 @@
|
|
|
169
168
|
"@tanstack/react-query": "^5.102.8",
|
|
170
169
|
"@tanstack/react-router": "^1.170.34",
|
|
171
170
|
"@tanstack/react-table": "^9.2.4",
|
|
171
|
+
"@tanstack/react-virtual": "^3.14.11",
|
|
172
172
|
"@types/bun": "latest",
|
|
173
173
|
"@types/d3-array": "^3.2.2",
|
|
174
174
|
"@types/d3-shape": "^3.2.0",
|
|
@@ -194,7 +194,6 @@
|
|
|
194
194
|
"date-fns": "^4.4.0",
|
|
195
195
|
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
|
196
196
|
"drizzle-orm": "1.0.0-rc.5-169397b",
|
|
197
|
-
"drizzle-seed": "^0.3.1",
|
|
198
197
|
"gflows": "^1.2.1",
|
|
199
198
|
"happy-dom": "^20.14.3",
|
|
200
199
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
|
@@ -231,7 +230,6 @@
|
|
|
231
230
|
"ajv-formats": ">=3.0.0",
|
|
232
231
|
"drizzle-kit": ">=1.0.0-rc.0",
|
|
233
232
|
"drizzle-orm": ">=1.0.0-rc.0",
|
|
234
|
-
"drizzle-seed": ">=0.3.0",
|
|
235
233
|
"react": ">=18.0.0",
|
|
236
234
|
"zod": ">=3.23.0"
|
|
237
235
|
},
|
|
@@ -275,9 +273,6 @@
|
|
|
275
273
|
"drizzle-orm": {
|
|
276
274
|
"optional": true
|
|
277
275
|
},
|
|
278
|
-
"drizzle-seed": {
|
|
279
|
-
"optional": true
|
|
280
|
-
},
|
|
281
276
|
"zod": {
|
|
282
277
|
"optional": true
|
|
283
278
|
}
|
package/src/cli/dev.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* context and exercises `oke.manifest.get` over HTTP on :6535.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
10
10
|
import { mkdir, mkdtemp, readdir } from "node:fs/promises";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
@@ -17,9 +17,19 @@ import {
|
|
|
17
17
|
} from "../compiler/generate-adopt.ts";
|
|
18
18
|
import type { Manifest } from "../manifest/types.ts";
|
|
19
19
|
import type { WideEvent } from "../runs/types.ts";
|
|
20
|
+
import type { ConsoleAppHandle } from "../console/server/app.ts";
|
|
21
|
+
import { createConsoleState } from "../console/server/state.ts";
|
|
20
22
|
import { isDataEnvelope, MCP_DATA_KIND } from "../mcp/data.ts";
|
|
21
23
|
import { mintMcpSession } from "../mcp/session.ts";
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
CLIENT_REGEN_DEBOUNCE_MS,
|
|
26
|
+
isClientRegenWatchPath,
|
|
27
|
+
isFlowsTreeWatchPath,
|
|
28
|
+
runDev,
|
|
29
|
+
type DevOptions,
|
|
30
|
+
type DevSession,
|
|
31
|
+
type DevWatchFn,
|
|
32
|
+
} from "./dev.ts";
|
|
23
33
|
import { mcpContextFromConsole } from "./mcp-from-console.ts";
|
|
24
34
|
|
|
25
35
|
/** Repo public entry — absolute import so temp apps need no install. */
|
|
@@ -803,6 +813,99 @@ describe("isFlowsTreeWatchPath", () => {
|
|
|
803
813
|
});
|
|
804
814
|
});
|
|
805
815
|
|
|
816
|
+
describe("isClientRegenWatchPath", () => {
|
|
817
|
+
test("flow files and schema declares regenerate the client; unrelated files do not", () => {
|
|
818
|
+
expect(isClientRegenWatchPath("flows/notes/get.ts")).toBe(true);
|
|
819
|
+
expect(isClientRegenWatchPath("schema.ts")).toBe(true);
|
|
820
|
+
expect(isClientRegenWatchPath("db/schema/links.ts")).toBe(true);
|
|
821
|
+
expect(isClientRegenWatchPath("lib/util.ts")).toBe(false);
|
|
822
|
+
expect(isClientRegenWatchPath("flows/index.ts")).toBe(false);
|
|
823
|
+
expect(isClientRegenWatchPath("flows/notes/get.ts.tmp")).toBe(false);
|
|
824
|
+
expect(isClientRegenWatchPath("db/drizzle/schema.ts")).toBe(false);
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
describe("oke dev client regen watch", () => {
|
|
829
|
+
let session: DevSession | undefined;
|
|
830
|
+
|
|
831
|
+
afterEach(() => {
|
|
832
|
+
session?.stop();
|
|
833
|
+
session = undefined;
|
|
834
|
+
mock.restore();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("three flow saves within 50ms regenerate the client once", async () => {
|
|
838
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-dev-regen-"));
|
|
839
|
+
await mkdir(join(dir, "src"), { recursive: true });
|
|
840
|
+
await Bun.write(join(dir, "src/app.ts"), "export {};\n");
|
|
841
|
+
|
|
842
|
+
const extractMod = await import("../compiler/extract.ts");
|
|
843
|
+
const originalExtract = extractMod.extractManifest;
|
|
844
|
+
let extracts = 0;
|
|
845
|
+
mock.module(resolve(import.meta.dir, "../compiler/extract.ts"), () => ({
|
|
846
|
+
...extractMod,
|
|
847
|
+
extractManifest: (opts: Parameters<typeof originalExtract>[0]) => {
|
|
848
|
+
extracts++;
|
|
849
|
+
return originalExtract(opts);
|
|
850
|
+
},
|
|
851
|
+
}));
|
|
852
|
+
|
|
853
|
+
let regens = 0;
|
|
854
|
+
let listener: Parameters<DevWatchFn>[2] | undefined;
|
|
855
|
+
const result = await runDev({
|
|
856
|
+
cwd: dir,
|
|
857
|
+
stdinIsTTY: false,
|
|
858
|
+
appPort: 0,
|
|
859
|
+
consolePort: 0,
|
|
860
|
+
mcpPort: 0,
|
|
861
|
+
...stubSurfaces(),
|
|
862
|
+
serveConsole: async () => ({
|
|
863
|
+
port: 0,
|
|
864
|
+
stop() {},
|
|
865
|
+
console: {
|
|
866
|
+
state: createConsoleState({ cwd: dir, secret: SECRET }),
|
|
867
|
+
} as ConsoleAppHandle,
|
|
868
|
+
}),
|
|
869
|
+
startApp: async () => ({
|
|
870
|
+
port: 0,
|
|
871
|
+
url: new URL("http://127.0.0.1:9"),
|
|
872
|
+
stop() {},
|
|
873
|
+
}),
|
|
874
|
+
syncAdoptBarrel: async () => [],
|
|
875
|
+
regenClient: async () => {
|
|
876
|
+
regens++;
|
|
877
|
+
},
|
|
878
|
+
watchFs: (_path, _options, next) => {
|
|
879
|
+
listener = next;
|
|
880
|
+
return { close() {} };
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
expect(result.code).toBe(0);
|
|
885
|
+
session = result.session;
|
|
886
|
+
const boot = regens;
|
|
887
|
+
expect(boot).toBe(1);
|
|
888
|
+
const extractsAfterBoot = extracts;
|
|
889
|
+
expect(extractsAfterBoot).toBeGreaterThan(0);
|
|
890
|
+
if (!listener) throw new Error("watcher not started");
|
|
891
|
+
|
|
892
|
+
listener("change", "flows/notes/get.ts");
|
|
893
|
+
listener("change", "flows/notes/list.ts");
|
|
894
|
+
listener("change", "flows/notes/[id]/delete.ts");
|
|
895
|
+
await Bun.sleep(50);
|
|
896
|
+
expect(regens).toBe(boot);
|
|
897
|
+
expect(extracts).toBe(extractsAfterBoot);
|
|
898
|
+
|
|
899
|
+
await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
|
|
900
|
+
expect(regens).toBe(boot + 1);
|
|
901
|
+
expect(extracts).toBe(extractsAfterBoot + 1);
|
|
902
|
+
|
|
903
|
+
listener("change", "lib/util.ts");
|
|
904
|
+
await Bun.sleep(CLIENT_REGEN_DEBOUNCE_MS + 80);
|
|
905
|
+
expect(regens).toBe(boot + 1);
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
806
909
|
describe("oke dev Docker-first", () => {
|
|
807
910
|
test("dryRun always plans compose (no mode ask)", async () => {
|
|
808
911
|
const dir = await mkdtemp(join(tmpdir(), "oke-dev-always-compose-"));
|
package/src/cli/dev.ts
CHANGED
|
@@ -1417,15 +1417,26 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1417
1417
|
|
|
1418
1418
|
const watchFs: DevWatchFn =
|
|
1419
1419
|
options.watchFs ?? ((path, watchOptions, listener) => watch(path, watchOptions, listener));
|
|
1420
|
+
// Trailing timer, always re-armed. A burst resets the window and still
|
|
1421
|
+
// fires once after it goes quiet — a timestamp check that only `return`s
|
|
1422
|
+
// drops the save and never regenerates.
|
|
1423
|
+
const clientRegen = createDebouncedRunner(() => {
|
|
1424
|
+
void regen(appUrl);
|
|
1425
|
+
}, CLIENT_REGEN_DEBOUNCE_MS);
|
|
1426
|
+
const manifestRefresh = createDebouncedRunner(() => {
|
|
1427
|
+
void refreshManifestInto(consoleState);
|
|
1428
|
+
}, CLIENT_REGEN_DEBOUNCE_MS);
|
|
1420
1429
|
const watcher = watchFs(resolve(cwd, "src"), { recursive: true }, (_event, filename) => {
|
|
1421
1430
|
const rel = (filename?.toString() ?? "").replace(/\\/g, "/");
|
|
1422
1431
|
if (isFlowsTreeWatchPath(rel)) {
|
|
1423
1432
|
void syncAdoptBarrel(cwd);
|
|
1424
1433
|
}
|
|
1425
|
-
|
|
1434
|
+
if (isClientRegenWatchPath(rel)) {
|
|
1435
|
+
clientRegen.trigger();
|
|
1436
|
+
}
|
|
1426
1437
|
// Only live-extract when the host did not pin a Manifest (tests).
|
|
1427
1438
|
if (options.manifest === undefined) {
|
|
1428
|
-
|
|
1439
|
+
manifestRefresh.trigger();
|
|
1429
1440
|
}
|
|
1430
1441
|
if (autoPushEnabled && isDomainSchemaWatchPath(filename?.toString())) {
|
|
1431
1442
|
lastSchemaFilename = filename?.toString() ?? "schema.ts";
|
|
@@ -1444,6 +1455,8 @@ export async function runDev(options: DevOptions = {}): Promise<DevResult> {
|
|
|
1444
1455
|
stopComposeHealthWatch = null;
|
|
1445
1456
|
bootBoard.stop();
|
|
1446
1457
|
autoPushRunner.cancel();
|
|
1458
|
+
clientRegen.cancel();
|
|
1459
|
+
manifestRefresh.cancel();
|
|
1447
1460
|
watcher.close();
|
|
1448
1461
|
const host = attachedHost;
|
|
1449
1462
|
attachedHost = null;
|
|
@@ -1613,6 +1626,28 @@ export function isFlowsTreeWatchPath(rel: string): boolean {
|
|
|
1613
1626
|
return true;
|
|
1614
1627
|
}
|
|
1615
1628
|
|
|
1629
|
+
/** Quiet window before one `oke-client.d.ts` regen after a burst of saves. */
|
|
1630
|
+
export const CLIENT_REGEN_DEBOUNCE_MS = 200;
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* True when a `src/` watcher event can change the typed client
|
|
1634
|
+
* (flow files, or a schema declare module those flows import).
|
|
1635
|
+
*
|
|
1636
|
+
* Editor temp files and the adopt barrel are excluded so a regen cannot
|
|
1637
|
+
* retrigger itself.
|
|
1638
|
+
*
|
|
1639
|
+
* @param rel - Watcher filename (POSIX or Windows)
|
|
1640
|
+
*/
|
|
1641
|
+
export function isClientRegenWatchPath(rel: string): boolean {
|
|
1642
|
+
const posix = rel.replace(/\\/g, "/");
|
|
1643
|
+
if (posix.endsWith(".tmp")) return false;
|
|
1644
|
+
if (isFlowsTreeWatchPath(posix)) return true;
|
|
1645
|
+
if (/(^|\/)drizzle\//.test(posix)) return false;
|
|
1646
|
+
if (/(^|\/)schema(\.decl)?\.tsx?$/.test(posix)) return true;
|
|
1647
|
+
if (/(^|\/)schema\/.+\.tsx?$/.test(posix)) return true;
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1616
1651
|
/**
|
|
1617
1652
|
* Frame a schema-sync failure for the `oke dev` loop: warn loud for developer
|
|
1618
1653
|
* bugs, soft-skip only known environmental gaps. Never crashes the session.
|
package/src/client/create.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Untriggered flows fall back to `POST /_oke/{unit}/{flow}` RPC.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createTransport, type Transport } from "./transport.ts";
|
|
15
|
+
import { createTransport, transportEnvelope, type Transport } from "./transport.ts";
|
|
16
16
|
import { asThenableIterable, attachPager } from "./pager.ts";
|
|
17
17
|
import {
|
|
18
18
|
flattenLiveRoutes,
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
LiveHandlers,
|
|
34
34
|
ResolveApp,
|
|
35
35
|
} from "./types.ts";
|
|
36
|
+
import { methodAndPath, walkContracts } from "./wire.ts";
|
|
36
37
|
|
|
37
38
|
/** App-shaped value that carries a runtime `$routes` table from typed adopt. */
|
|
38
39
|
export interface AppWithRoutes {
|
|
@@ -169,19 +170,11 @@ function mergeHeaders(base: ClientHeaders | undefined, over: ClientHeaders): Cli
|
|
|
169
170
|
export function flattenRoutes(
|
|
170
171
|
$routes: ClientRouteMap | undefined,
|
|
171
172
|
): ClientOptions["routes"] | undefined {
|
|
172
|
-
if (!$routes) return undefined;
|
|
173
173
|
const out: Record<string, { readonly method: string; readonly path: string }> = {};
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const method = "method" in contract ? contract.method : undefined;
|
|
179
|
-
const path = "path" in contract ? contract.path : undefined;
|
|
180
|
-
if (typeof method === "string" && typeof path === "string") {
|
|
181
|
-
out[`${unit}.${flow}`] = { method, path };
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
174
|
+
walkContracts($routes, (unit, flow, contract) => {
|
|
175
|
+
const route = methodAndPath(contract);
|
|
176
|
+
if (route) out[`${unit}.${flow}`] = route;
|
|
177
|
+
});
|
|
185
178
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
186
179
|
}
|
|
187
180
|
|
|
@@ -196,25 +189,19 @@ function proxy(transport: Transport, path: readonly string[], ctx: ProxyCtx): un
|
|
|
196
189
|
if (path.length < 2) {
|
|
197
190
|
const message = `Incomplete path: api.${path.join(".") || "?"}(…)`;
|
|
198
191
|
return attachPager(
|
|
199
|
-
|
|
200
|
-
data: null,
|
|
201
|
-
error: {
|
|
202
|
-
code: "TransportError" as const,
|
|
203
|
-
message,
|
|
204
|
-
data: { message },
|
|
205
|
-
},
|
|
206
|
-
},
|
|
192
|
+
transportEnvelope(message),
|
|
207
193
|
(nextInput) => invoke(nextInput, callOpts),
|
|
208
194
|
input,
|
|
209
195
|
);
|
|
210
196
|
}
|
|
211
197
|
const unit = path[0]!;
|
|
212
198
|
const flow = path.slice(1).join(".");
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
199
|
+
const headers = ctx.perCallHeaders.read();
|
|
200
|
+
const result = await transport.call(
|
|
201
|
+
`${unit}/${flow}`,
|
|
202
|
+
input,
|
|
203
|
+
callOpts ? { headers, ...callOpts } : headers,
|
|
204
|
+
);
|
|
218
205
|
return attachPager(result, (nextInput) => invoke(nextInput, callOpts), input);
|
|
219
206
|
};
|
|
220
207
|
const call = (a?: unknown, b?: unknown): unknown => {
|
package/src/client/live.ts
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ClientFetch, ClientOptions, ClientRouteMap, FlowContract } from "./types.ts";
|
|
6
6
|
import type { LiveHandlers, LiveUnsubscribe } from "./types.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { iterateSseFrames, sseError } from "./sse.ts";
|
|
8
|
+
import {
|
|
9
|
+
applyAuthHeader,
|
|
10
|
+
applyHeaderBag,
|
|
11
|
+
interpolatePath,
|
|
12
|
+
methodAndPath,
|
|
13
|
+
resolveHeaders,
|
|
14
|
+
toQuery,
|
|
15
|
+
walkContracts,
|
|
16
|
+
} from "./wire.ts";
|
|
8
17
|
|
|
9
18
|
/** One HTTP exposure of a live signal. */
|
|
10
19
|
export interface LiveExposure {
|
|
@@ -31,24 +40,16 @@ export function flattenLiveRoutes($routes: ClientRouteMap | undefined): {
|
|
|
31
40
|
} {
|
|
32
41
|
const bySignal: Record<string, LiveExposure[]> = {};
|
|
33
42
|
const byFlow: Record<string, LiveExposure> = {};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
const matchKey = matchKeyOf(contract);
|
|
46
|
-
const id = `${unit}.${flow}`;
|
|
47
|
-
const exposure: LiveExposure = { flow: id, method, path, matchKey };
|
|
48
|
-
(bySignal[live] ??= []).push(exposure);
|
|
49
|
-
byFlow[id] = exposure;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
43
|
+
walkContracts($routes, (unit, flow, contract) => {
|
|
44
|
+
const live = liveName(contract);
|
|
45
|
+
const route = methodAndPath(contract);
|
|
46
|
+
if (typeof live !== "string" || !route) return;
|
|
47
|
+
const matchKey = matchKeyOf(contract);
|
|
48
|
+
const id = `${unit}.${flow}`;
|
|
49
|
+
const exposure: LiveExposure = { flow: id, method: route.method, path: route.path, matchKey };
|
|
50
|
+
(bySignal[live] ??= []).push(exposure);
|
|
51
|
+
byFlow[id] = exposure;
|
|
52
|
+
});
|
|
52
53
|
return { bySignal, byFlow };
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -87,11 +88,7 @@ export function pickLiveExposure(
|
|
|
87
88
|
): LiveExposure {
|
|
88
89
|
if (via) {
|
|
89
90
|
const hit = exposures.find((e) => e.flow === via);
|
|
90
|
-
if (!hit) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
`Unknown live via "${via}". Known: ${exposures.map((e) => e.flow).join(", ")}`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
91
|
+
if (!hit) throw new Error(`Unknown live via "${via}"`);
|
|
95
92
|
return hit;
|
|
96
93
|
}
|
|
97
94
|
const keys =
|
|
@@ -100,19 +97,12 @@ export function pickLiveExposure(
|
|
|
100
97
|
: [];
|
|
101
98
|
const candidates = exposures.filter((e) => e.matchKey.every((k) => keys.includes(k)));
|
|
102
99
|
if (candidates.length === 0) {
|
|
103
|
-
throw new Error(
|
|
104
|
-
`No live exposure matches input keys [${keys.join(", ")}]. Flows: ${exposures.map((e) => e.flow).join(", ")}`,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
let max = -1;
|
|
108
|
-
for (const e of candidates) {
|
|
109
|
-
if (e.matchKey.length > max) max = e.matchKey.length;
|
|
100
|
+
throw new Error(`No live exposure matches input keys [${keys.join(", ")}]`);
|
|
110
101
|
}
|
|
102
|
+
const max = Math.max(...candidates.map((e) => e.matchKey.length));
|
|
111
103
|
const top = candidates.filter((e) => e.matchKey.length === max);
|
|
112
104
|
if (top.length !== 1) {
|
|
113
|
-
throw new Error(
|
|
114
|
-
`Multiple live exposures match: ${top.map((e) => e.flow).join(", ")}. Pass via: "unit.flow".`,
|
|
115
|
-
);
|
|
105
|
+
throw new Error(`Multiple live exposures match. Pass via: "unit.flow".`);
|
|
116
106
|
}
|
|
117
107
|
return top[0]!;
|
|
118
108
|
}
|
|
@@ -173,6 +163,11 @@ async function pump(
|
|
|
173
163
|
let delayMs = LIVE_RESUBSCRIBE_INITIAL_MS;
|
|
174
164
|
let attempt = 0;
|
|
175
165
|
let lastSeenId: string | undefined;
|
|
166
|
+
/** Report `err`. Returns whether the loop should resubscribe. */
|
|
167
|
+
const note = (err: unknown): boolean => {
|
|
168
|
+
handlers.onError?.(err);
|
|
169
|
+
return auto;
|
|
170
|
+
};
|
|
176
171
|
for (;;) {
|
|
177
172
|
if (signal.aborted) return;
|
|
178
173
|
if (attempt > 0 && auto) {
|
|
@@ -182,45 +177,26 @@ async function pump(
|
|
|
182
177
|
}
|
|
183
178
|
attempt += 1;
|
|
184
179
|
try {
|
|
185
|
-
|
|
180
|
+
let res = await openSse(base, exposure, input, opts, signal, lastSeenId);
|
|
186
181
|
if (signal.aborted) return;
|
|
187
182
|
if (res.status === 401 && opts.auth?.refresh) {
|
|
188
183
|
await opts.auth.refresh();
|
|
189
|
-
|
|
190
|
-
if (signal.aborted) return;
|
|
191
|
-
lastSeenId = await consumeSse(retry, handlers, signal, lastSeenId);
|
|
184
|
+
res = await openSse(base, exposure, input, opts, signal, lastSeenId);
|
|
192
185
|
if (signal.aborted) return;
|
|
193
|
-
const closed = new Error("live connection closed");
|
|
194
|
-
if (auto) {
|
|
195
|
-
handlers.onError?.(closed);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
handlers.onError?.(closed);
|
|
199
|
-
return;
|
|
200
186
|
}
|
|
201
187
|
if (res.status === 410) {
|
|
202
|
-
const err = await liveResumeGapError(res);
|
|
203
188
|
lastSeenId = undefined;
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
189
|
+
const text = await res.text().catch(() => "");
|
|
190
|
+
if (note(sseError(410, text))) continue;
|
|
191
|
+
return;
|
|
207
192
|
}
|
|
208
193
|
lastSeenId = await consumeSse(res, handlers, signal, lastSeenId);
|
|
209
194
|
if (signal.aborted) return;
|
|
210
|
-
|
|
211
|
-
if (auto) {
|
|
212
|
-
handlers.onError?.(closed);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
handlers.onError?.(closed);
|
|
195
|
+
if (note(new Error("live connection closed"))) continue;
|
|
216
196
|
return;
|
|
217
197
|
} catch (err) {
|
|
218
198
|
if (signal.aborted) return;
|
|
219
|
-
if (
|
|
220
|
-
handlers.onError?.(err);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
handlers.onError?.(err);
|
|
199
|
+
if (note(err)) continue;
|
|
224
200
|
return;
|
|
225
201
|
}
|
|
226
202
|
}
|
|
@@ -233,23 +209,13 @@ async function consumeSse(
|
|
|
233
209
|
lastSeenId: string | undefined,
|
|
234
210
|
): Promise<string | undefined> {
|
|
235
211
|
let cursor = lastSeenId;
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
(
|
|
239
|
-
|
|
240
|
-
if (id !== undefined && id.length > 0) cursor = id;
|
|
241
|
-
},
|
|
242
|
-
signal,
|
|
243
|
-
handlers.onOpen,
|
|
244
|
-
);
|
|
212
|
+
for await (const frame of iterateSseFrames(res, signal, handlers.onOpen)) {
|
|
213
|
+
handlers.onEvent(frame.event);
|
|
214
|
+
if (frame.id !== undefined && frame.id.length > 0) cursor = frame.id;
|
|
215
|
+
}
|
|
245
216
|
return cursor;
|
|
246
217
|
}
|
|
247
218
|
|
|
248
|
-
async function liveResumeGapError(res: Response): Promise<Error> {
|
|
249
|
-
const text = await res.text().catch(() => "");
|
|
250
|
-
return sseError(410, text);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
219
|
/**
|
|
254
220
|
* Abortable delay. Resolves immediately when `signal` is already aborted.
|
|
255
221
|
*
|
|
@@ -280,19 +246,8 @@ async function openSse(
|
|
|
280
246
|
): Promise<Response> {
|
|
281
247
|
const { url, method } = restGet(base, exposure.path, input);
|
|
282
248
|
const headers = new Headers({ accept: "text/event-stream" });
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
for (const [k, v] of extra) headers.set(k, v);
|
|
286
|
-
} else if (extra) {
|
|
287
|
-
for (const [k, v] of Object.entries(extra)) headers.set(k, v);
|
|
288
|
-
}
|
|
289
|
-
const token =
|
|
290
|
-
opts.auth && "getToken" in opts.auth && typeof opts.auth.getToken === "function"
|
|
291
|
-
? await opts.auth.getToken()
|
|
292
|
-
: undefined;
|
|
293
|
-
if (token && !headers.has("authorization")) {
|
|
294
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
295
|
-
}
|
|
249
|
+
applyHeaderBag(headers, await resolveHeaders(opts));
|
|
250
|
+
await applyAuthHeader(headers, opts);
|
|
296
251
|
if (lastSeenId) headers.set("last-event-id", lastSeenId);
|
|
297
252
|
const fetchFn: ClientFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
298
253
|
return fetchFn(url, {
|
|
@@ -304,18 +259,6 @@ async function openSse(
|
|
|
304
259
|
}
|
|
305
260
|
|
|
306
261
|
function restGet(base: string, path: string, input: unknown): { url: string; method: string } {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
let pathOut = path;
|
|
310
|
-
const query: string[] = [];
|
|
311
|
-
for (const [k, v] of Object.entries(params)) {
|
|
312
|
-
const token = `:${k}`;
|
|
313
|
-
if (pathOut.includes(token)) {
|
|
314
|
-
pathOut = pathOut.replaceAll(token, encodeURIComponent(String(v)));
|
|
315
|
-
} else if (v !== undefined) {
|
|
316
|
-
query.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const qs = query.length ? `?${query.join("&")}` : "";
|
|
320
|
-
return { url: `${base}${pathOut}${qs}`, method: "GET" };
|
|
262
|
+
const { path: pathOut, rest } = interpolatePath(path, input);
|
|
263
|
+
return { url: `${base}${pathOut}${toQuery(rest)}`, method: "GET" };
|
|
321
264
|
}
|
package/src/client/sse.ts
CHANGED
|
@@ -4,33 +4,30 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
export
|
|
7
|
+
/** One parsed SSE data frame. */
|
|
8
|
+
export interface ParsedSseFrame {
|
|
9
|
+
readonly event: unknown;
|
|
10
|
+
readonly id: string | undefined;
|
|
11
|
+
}
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
*
|
|
14
|
+
* Shared frame pump. `[DONE]` and abort end the iterator without throwing.
|
|
12
15
|
*
|
|
13
|
-
* @param res - Fetch response
|
|
14
|
-
* @param onEvent - Frame handler
|
|
16
|
+
* @param res - Fetch response
|
|
15
17
|
* @param signal - Abort signal
|
|
16
18
|
* @param onOpen - Called after content-type validation
|
|
17
19
|
*/
|
|
18
|
-
export async function
|
|
20
|
+
export async function* iterateSseFrames(
|
|
19
21
|
res: Response,
|
|
20
|
-
onEvent: SseFrameHandler,
|
|
21
22
|
signal: AbortSignal,
|
|
22
23
|
onOpen?: () => void,
|
|
23
|
-
):
|
|
24
|
+
): AsyncGenerator<ParsedSseFrame> {
|
|
24
25
|
if (signal.aborted) return;
|
|
25
26
|
const ct = res.headers.get("content-type") ?? "";
|
|
26
|
-
if (!res.ok || !res.body) {
|
|
27
|
+
if (!res.ok || !res.body || !ct.includes("text/event-stream")) {
|
|
27
28
|
const text = await res.text().catch(() => "");
|
|
28
29
|
throw sseError(res.status, text);
|
|
29
30
|
}
|
|
30
|
-
if (!ct.includes("text/event-stream")) {
|
|
31
|
-
const text = await res.text().catch(() => "");
|
|
32
|
-
throw sseError(res.status, text || `Expected text/event-stream, got ${ct || "none"}`);
|
|
33
|
-
}
|
|
34
31
|
onOpen?.();
|
|
35
32
|
const reader = res.body.getReader();
|
|
36
33
|
const dec = new TextDecoder();
|
|
@@ -39,14 +36,15 @@ export async function readSse(
|
|
|
39
36
|
for (;;) {
|
|
40
37
|
if (signal.aborted) return;
|
|
41
38
|
const { done, value } = await reader.read();
|
|
42
|
-
if (done)
|
|
39
|
+
if (done) return;
|
|
43
40
|
buf += dec.decode(value, { stream: true });
|
|
44
41
|
let sep = buf.indexOf("\n\n");
|
|
45
42
|
while (sep >= 0) {
|
|
46
43
|
const raw = buf.slice(0, sep);
|
|
47
44
|
buf = buf.slice(sep + 2);
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
45
|
+
const frame = parseFrame(raw);
|
|
46
|
+
if (frame === "done" || signal.aborted) return;
|
|
47
|
+
if (frame) yield frame;
|
|
50
48
|
sep = buf.indexOf("\n\n");
|
|
51
49
|
}
|
|
52
50
|
}
|
|
@@ -56,65 +54,21 @@ export async function readSse(
|
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
/**
|
|
59
|
-
*
|
|
57
|
+
* Parse one SSE block. `"done"` stops the pump; `null` skips empty blocks.
|
|
60
58
|
*
|
|
61
|
-
* @param
|
|
62
|
-
* @param signal - Abort signal
|
|
59
|
+
* @param raw - Text between blank lines
|
|
63
60
|
*/
|
|
64
|
-
|
|
65
|
-
const queue: unknown[] = [];
|
|
66
|
-
let wake: (() => void) | undefined;
|
|
67
|
-
let done = false;
|
|
68
|
-
let err: unknown;
|
|
69
|
-
|
|
70
|
-
const pump = readSse(
|
|
71
|
-
res,
|
|
72
|
-
(event) => {
|
|
73
|
-
queue.push(event);
|
|
74
|
-
wake?.();
|
|
75
|
-
},
|
|
76
|
-
signal,
|
|
77
|
-
)
|
|
78
|
-
.then(() => {
|
|
79
|
-
done = true;
|
|
80
|
-
wake?.();
|
|
81
|
-
})
|
|
82
|
-
.catch((e) => {
|
|
83
|
-
err = e;
|
|
84
|
-
done = true;
|
|
85
|
-
wake?.();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
for (;;) {
|
|
90
|
-
while (queue.length > 0) {
|
|
91
|
-
yield queue.shift();
|
|
92
|
-
}
|
|
93
|
-
if (done) break;
|
|
94
|
-
await new Promise<void>((r) => {
|
|
95
|
-
wake = r;
|
|
96
|
-
});
|
|
97
|
-
wake = undefined;
|
|
98
|
-
}
|
|
99
|
-
if (err) throw err;
|
|
100
|
-
} finally {
|
|
101
|
-
await pump.catch(() => {});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function dispatchFrame(raw: string, onEvent: SseFrameHandler, signal: AbortSignal): boolean {
|
|
61
|
+
function parseFrame(raw: string): ParsedSseFrame | "done" | null {
|
|
106
62
|
const dataLines: string[] = [];
|
|
107
63
|
let id: string | undefined;
|
|
108
64
|
for (const line of raw.split("\n")) {
|
|
109
65
|
if (line.startsWith("id:")) id = line.slice(3).replace(/^ /, "");
|
|
110
66
|
if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
111
67
|
}
|
|
112
|
-
if (dataLines.length === 0) return
|
|
68
|
+
if (dataLines.length === 0) return null;
|
|
113
69
|
const data = dataLines.join("\n");
|
|
114
|
-
if (data === "[DONE]") return
|
|
115
|
-
|
|
116
|
-
onEvent(JSON.parse(data) as unknown, id);
|
|
117
|
-
return false;
|
|
70
|
+
if (data === "[DONE]") return "done";
|
|
71
|
+
return { event: JSON.parse(data) as unknown, id };
|
|
118
72
|
}
|
|
119
73
|
|
|
120
74
|
/** Build an Error with optional HTTP status. */
|