@norskvideo/ctl-sdk 0.1.1 → 0.1.3
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/browser.d.ts +1 -0
- package/browser.js +1 -0
- package/components/ProductIframe.d.ts +2 -7
- package/components/ProductIframe.js +16 -11
- package/components/ProductTemplateBuildForm.js +15 -9
- package/http-proxy.d.ts +34 -0
- package/http-proxy.js +131 -0
- package/iframe-protocol.d.ts +112 -0
- package/iframe-protocol.js +44 -0
- package/index.d.ts +3 -0
- package/index.js +3 -0
- package/manifest-schema.d.ts +12 -5
- package/manifest-schema.js +14 -5
- package/manifest-seed.d.ts +2 -0
- package/manifest-seed.js +5 -0
- package/package.json +1 -1
- package/product-template-error.d.ts +2 -2
- package/proxy-middleware.js +5 -66
- package/route-error.d.ts +12 -0
- package/route-error.js +26 -0
- package/sse.d.ts +12 -0
- package/sse.js +42 -0
- package/workflow.d.ts +40 -0
- package/workflow.js +70 -0
package/browser.d.ts
CHANGED
package/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Browser-safe surface: types, schemas, parsers, validators.
|
|
2
2
|
// Anything with Node-only deps (fs, child_process, express) lives in the
|
|
3
3
|
// default entry only.
|
|
4
|
+
export * from "./iframe-protocol.js";
|
|
4
5
|
export * from "./manifest-schema.js";
|
|
5
6
|
export * from "./parsing.js";
|
|
6
7
|
export * from "./product-error.js";
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
formValues: Record<string, unknown>;
|
|
4
|
-
} | {
|
|
5
|
-
ok: false;
|
|
6
|
-
error?: string;
|
|
7
|
-
};
|
|
1
|
+
import { type SubmitResult } from "../iframe-protocol.js";
|
|
2
|
+
export type { SubmitResult };
|
|
8
3
|
export type ProductIframeHandle = {
|
|
9
4
|
submit: () => Promise<SubmitResult>;
|
|
10
5
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
-
|
|
3
|
+
import { IFRAME_PROTOCOL_VERSION, parseChildMessage, } from "../iframe-protocol.js";
|
|
4
4
|
const SUBMIT_TIMEOUT_MS = 30_000;
|
|
5
5
|
function resolveSrc(productName, configScreenUrl) {
|
|
6
6
|
const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
|
|
@@ -27,13 +27,14 @@ export const ProductIframe = forwardRef(function ProductIframe({ productName, co
|
|
|
27
27
|
const onMessage = (event) => {
|
|
28
28
|
if (event.source !== iframeRef.current?.contentWindow)
|
|
29
29
|
return;
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
30
|
+
const msg = parseChildMessage(event.data);
|
|
31
|
+
if (!msg)
|
|
32
32
|
return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// Payload fields stay runtime-checked despite the typed union: the wire
|
|
34
|
+
// is a product we don't control, and legacy shapes (option-a
|
|
35
|
+
// submit-result) predate it.
|
|
36
|
+
const payload = (msg.payload ?? {});
|
|
37
|
+
switch (msg.type) {
|
|
37
38
|
case "ready":
|
|
38
39
|
case "resize":
|
|
39
40
|
if (typeof payload.height === "number" && payload.height > 0)
|
|
@@ -88,16 +89,19 @@ export const ProductIframe = forwardRef(function ProductIframe({ productName, co
|
|
|
88
89
|
const launchContextRef = useRef(launchContext);
|
|
89
90
|
launchContextRef.current = launchContext;
|
|
90
91
|
const sendInit = useCallback(() => {
|
|
91
|
-
|
|
92
|
-
v:
|
|
92
|
+
const init = {
|
|
93
|
+
v: IFRAME_PROTOCOL_VERSION,
|
|
93
94
|
type: "init",
|
|
94
95
|
payload: {
|
|
95
96
|
instanceContext: { basename },
|
|
96
97
|
theme: "dark",
|
|
97
98
|
locale: "en",
|
|
99
|
+
// The prop is a verbatim-forwarded record by design; the protocol
|
|
100
|
+
// types the keys the products read.
|
|
98
101
|
...(launchContextRef.current ? { launchContext: launchContextRef.current } : {}),
|
|
99
102
|
},
|
|
100
|
-
}
|
|
103
|
+
};
|
|
104
|
+
iframeRef.current?.contentWindow?.postMessage(init, "*");
|
|
101
105
|
}, [basename]);
|
|
102
106
|
useImperativeHandle(ref, () => ({
|
|
103
107
|
submit: () => new Promise((resolve) => {
|
|
@@ -106,7 +110,8 @@ export const ProductIframe = forwardRef(function ProductIframe({ productName, co
|
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
108
112
|
submitResolverRef.current = resolve;
|
|
109
|
-
|
|
113
|
+
const submit = { v: IFRAME_PROTOCOL_VERSION, type: "submit", payload: {} };
|
|
114
|
+
iframeRef.current.contentWindow.postMessage(submit, "*");
|
|
110
115
|
setTimeout(() => {
|
|
111
116
|
if (submitResolverRef.current === resolve) {
|
|
112
117
|
submitResolverRef.current = null;
|
|
@@ -31,16 +31,22 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
31
31
|
// caller can hand it off to its launch flow without waiting for a refetch),
|
|
32
32
|
// null on failure.
|
|
33
33
|
const saveProductTemplate = async () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
// No config screen declared: there is nothing to collect, so the build
|
|
35
|
+
// request carries empty form values rather than requiring an iframe.
|
|
36
|
+
let formValues = {};
|
|
37
|
+
if (configScreenUrl) {
|
|
38
|
+
if (!iframeRef.current) {
|
|
39
|
+
toast.error("Configuration not ready");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const result = await iframeRef.current.submit();
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
toast.error(result.error ?? "Configuration submission failed");
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
formValues = result.formValues;
|
|
42
48
|
}
|
|
43
|
-
const outcome = await onSubmit({ productName, productTemplateName, formValues
|
|
49
|
+
const outcome = await onSubmit({ productName, productTemplateName, formValues });
|
|
44
50
|
if (!outcome.ok) {
|
|
45
51
|
toast.error(`Save product template failed: ${outcome.error}`);
|
|
46
52
|
return null;
|
package/http-proxy.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Request, RequestHandler, Response } from "express";
|
|
2
|
+
export declare const HOP_BY_HOP_HEADERS: ReadonlySet<string>;
|
|
3
|
+
/** Forwardable copy of an incoming request's headers: hop-by-hop, host and
|
|
4
|
+
* content-length stripped (fetch derives the latter two from the target URL
|
|
5
|
+
* and the buffered body). */
|
|
6
|
+
export declare function buildForwardHeaders(req: Request): Headers;
|
|
7
|
+
/** Buffer the request body (small JSON control messages — buffering is robust
|
|
8
|
+
* across the fetch duplex-body edge cases). GET/HEAD and empty bodies yield
|
|
9
|
+
* undefined. */
|
|
10
|
+
export declare function readRequestBody(req: Request): Promise<Uint8Array | undefined>;
|
|
11
|
+
export type ForwardResponseOptions = {
|
|
12
|
+
/** Transform response header values before relaying (e.g. re-prefix a
|
|
13
|
+
* root-absolute redirect Location). Return the value to send. */
|
|
14
|
+
rewriteHeader?: (key: string, value: string) => string;
|
|
15
|
+
/** Observe mid-stream body failures (the response is ended regardless). */
|
|
16
|
+
onStreamError?: (err: unknown) => void;
|
|
17
|
+
};
|
|
18
|
+
/** Relay an upstream fetch Response: status, headers (hop-by-hop plus
|
|
19
|
+
* content-length/content-encoding stripped — fetch already decompressed the
|
|
20
|
+
* body), then the streamed body. */
|
|
21
|
+
export declare function forwardUpstreamResponse(res: Response, upstream: globalThis.Response, opts?: ForwardResponseOptions): Promise<void>;
|
|
22
|
+
export type ProxyHttpOptions = ForwardResponseOptions & {
|
|
23
|
+
/** Abort the upstream fetch (headers AND body) after this long. Defaults to
|
|
24
|
+
* 30s; pass 0 for no timeout (long-lived streams). */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Proxy one HTTP request to `targetUrl`, streaming the response body back.
|
|
28
|
+
* Never throws into express: an unreachable/booting upstream surfaces as a
|
|
29
|
+
* 502 the caller retries. */
|
|
30
|
+
export declare function proxyHttp(req: Request, res: Response, targetUrl: string, opts?: ProxyHttpOptions): Promise<void>;
|
|
31
|
+
/** Express handler proxying whatever path it is mounted on to
|
|
32
|
+
* `resolveBase(req) + req.originalUrl` (so /live/api/<id>/x reaches
|
|
33
|
+
* <studio>/live/api/<id>/x). */
|
|
34
|
+
export declare function makeHttpProxy(resolveBase: (req: Request) => string, opts?: ProxyHttpOptions): RequestHandler;
|
package/http-proxy.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Dependency-free fetch-based reverse-proxy core, shared by the runner's
|
|
2
|
+
// product proxy (proxy-middleware.ts) and by instance-side product backends
|
|
3
|
+
// fronting a launched Studio (lifted from funke's live-proxy.ts).
|
|
4
|
+
//
|
|
5
|
+
// Zero new dependency is deliberate: `bun build --target=bun` can mis-bundle a
|
|
6
|
+
// fat CJS proxy dep, only caught in the image. A hand-rolled proxy bundles
|
|
7
|
+
// trivially. WebSocket upgrades are OUT of scope: bun's node:http "upgrade"
|
|
8
|
+
// socket is not writable back to the client, so an in-process WS relay cannot
|
|
9
|
+
// forward the 101 — bridge upstream WS feeds by consuming them as a client and
|
|
10
|
+
// re-emitting same-origin SSE instead (see sse.ts).
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
// RFC 7230 §6.1 hop-by-hop headers, which must not travel end-to-end. Includes
|
|
13
|
+
// both "trailer" (the actual header) and "trailers" (the TE value, present in
|
|
14
|
+
// one of the copies this replaces) so neither survives in either direction.
|
|
15
|
+
export const HOP_BY_HOP_HEADERS = new Set([
|
|
16
|
+
"connection",
|
|
17
|
+
"keep-alive",
|
|
18
|
+
"proxy-authenticate",
|
|
19
|
+
"proxy-authorization",
|
|
20
|
+
"te",
|
|
21
|
+
"trailer",
|
|
22
|
+
"trailers",
|
|
23
|
+
"transfer-encoding",
|
|
24
|
+
"upgrade",
|
|
25
|
+
]);
|
|
26
|
+
// fetch decompresses upstream bodies and sets host/content-length itself, so
|
|
27
|
+
// the originals must not be relayed alongside the transformed message.
|
|
28
|
+
const REQUEST_ONLY_STRIP = new Set(["host", "content-length"]);
|
|
29
|
+
const RESPONSE_ONLY_STRIP = new Set(["content-length", "content-encoding"]);
|
|
30
|
+
/** Forwardable copy of an incoming request's headers: hop-by-hop, host and
|
|
31
|
+
* content-length stripped (fetch derives the latter two from the target URL
|
|
32
|
+
* and the buffered body). */
|
|
33
|
+
export function buildForwardHeaders(req) {
|
|
34
|
+
const headers = new Headers();
|
|
35
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
36
|
+
const lower = key.toLowerCase();
|
|
37
|
+
if (HOP_BY_HOP_HEADERS.has(lower) || REQUEST_ONLY_STRIP.has(lower))
|
|
38
|
+
continue;
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (const v of value)
|
|
41
|
+
headers.append(key, v);
|
|
42
|
+
}
|
|
43
|
+
else if (value !== undefined) {
|
|
44
|
+
headers.set(key, value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return headers;
|
|
48
|
+
}
|
|
49
|
+
/** Buffer the request body (small JSON control messages — buffering is robust
|
|
50
|
+
* across the fetch duplex-body edge cases). GET/HEAD and empty bodies yield
|
|
51
|
+
* undefined. */
|
|
52
|
+
export function readRequestBody(req) {
|
|
53
|
+
const method = req.method.toUpperCase();
|
|
54
|
+
if (method === "GET" || method === "HEAD")
|
|
55
|
+
return Promise.resolve(undefined);
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
req.on("data", (c) => chunks.push(c));
|
|
59
|
+
req.on("end", () => {
|
|
60
|
+
const buf = Buffer.concat(chunks);
|
|
61
|
+
resolve(buf.byteLength > 0 ? new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) : undefined);
|
|
62
|
+
});
|
|
63
|
+
req.on("error", reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Relay an upstream fetch Response: status, headers (hop-by-hop plus
|
|
67
|
+
* content-length/content-encoding stripped — fetch already decompressed the
|
|
68
|
+
* body), then the streamed body. */
|
|
69
|
+
export async function forwardUpstreamResponse(res, upstream, opts = {}) {
|
|
70
|
+
res.status(upstream.status);
|
|
71
|
+
upstream.headers.forEach((value, key) => {
|
|
72
|
+
const lower = key.toLowerCase();
|
|
73
|
+
if (HOP_BY_HOP_HEADERS.has(lower) || RESPONSE_ONLY_STRIP.has(lower))
|
|
74
|
+
return;
|
|
75
|
+
res.setHeader(key, opts.rewriteHeader ? opts.rewriteHeader(lower, value) : value);
|
|
76
|
+
});
|
|
77
|
+
if (!upstream.body) {
|
|
78
|
+
res.end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const nodeStream = Readable.fromWeb(upstream.body);
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
nodeStream.on("error", (err) => {
|
|
84
|
+
opts.onStreamError?.(err);
|
|
85
|
+
if (!res.headersSent)
|
|
86
|
+
res.status(502);
|
|
87
|
+
res.end();
|
|
88
|
+
resolve();
|
|
89
|
+
});
|
|
90
|
+
res.on("close", resolve);
|
|
91
|
+
nodeStream.pipe(res);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const DEFAULT_PROXY_TIMEOUT_MS = 30_000;
|
|
95
|
+
/** Proxy one HTTP request to `targetUrl`, streaming the response body back.
|
|
96
|
+
* Never throws into express: an unreachable/booting upstream surfaces as a
|
|
97
|
+
* 502 the caller retries. */
|
|
98
|
+
export async function proxyHttp(req, res, targetUrl, opts = {}) {
|
|
99
|
+
const headers = buildForwardHeaders(req);
|
|
100
|
+
const body = await readRequestBody(req);
|
|
101
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS;
|
|
102
|
+
let upstream;
|
|
103
|
+
try {
|
|
104
|
+
upstream = await fetch(targetUrl, {
|
|
105
|
+
method: req.method.toUpperCase(),
|
|
106
|
+
headers,
|
|
107
|
+
body: body,
|
|
108
|
+
redirect: "manual",
|
|
109
|
+
...(timeoutMs > 0 ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (!res.headersSent) {
|
|
114
|
+
res.status(502).json({ error: "bad gateway", message: err instanceof Error ? err.message : String(err) });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
res.end();
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await forwardUpstreamResponse(res, upstream, opts);
|
|
122
|
+
}
|
|
123
|
+
/** Express handler proxying whatever path it is mounted on to
|
|
124
|
+
* `resolveBase(req) + req.originalUrl` (so /live/api/<id>/x reaches
|
|
125
|
+
* <studio>/live/api/<id>/x). */
|
|
126
|
+
export function makeHttpProxy(resolveBase, opts = {}) {
|
|
127
|
+
return (req, res) => {
|
|
128
|
+
const target = resolveBase(req).replace(/\/+$/, "") + req.originalUrl;
|
|
129
|
+
void proxyHttp(req, res, target, opts);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export declare const IFRAME_PROTOCOL_VERSION = 1;
|
|
2
|
+
export type IframeTheme = "light" | "dark";
|
|
3
|
+
export type InstanceContext = {
|
|
4
|
+
/** Path prefix where the product is mounted on the runner's origin (e.g.
|
|
5
|
+
* `/products/norsk-probe`) — feed it to the product router's basename. */
|
|
6
|
+
basename?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Present only for the instance-launch config screen: the product template
|
|
9
|
+
* being launched (name + parameters), so the screen can prefill. */
|
|
10
|
+
export type LaunchContext = {
|
|
11
|
+
productTemplateName?: string;
|
|
12
|
+
parameters?: {
|
|
13
|
+
name: string;
|
|
14
|
+
default?: string;
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
17
|
+
/** The iframe reports the form values it captured; the runner POSTs them to
|
|
18
|
+
* the product's /api/product-template to get the tar. formValues is
|
|
19
|
+
* product-specific. */
|
|
20
|
+
export type SubmitResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
formValues: Record<string, unknown>;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
export type IframeChildMessage = {
|
|
28
|
+
v: 1;
|
|
29
|
+
type: "ready";
|
|
30
|
+
payload: {
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
} | {
|
|
34
|
+
v: 1;
|
|
35
|
+
type: "resize";
|
|
36
|
+
payload: {
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
} | {
|
|
40
|
+
v: 1;
|
|
41
|
+
type: "validity";
|
|
42
|
+
payload: {
|
|
43
|
+
valid: boolean;
|
|
44
|
+
errors?: string[];
|
|
45
|
+
};
|
|
46
|
+
} | {
|
|
47
|
+
v: 1;
|
|
48
|
+
type: "suggest-name";
|
|
49
|
+
payload: {
|
|
50
|
+
name: string;
|
|
51
|
+
};
|
|
52
|
+
} | {
|
|
53
|
+
v: 1;
|
|
54
|
+
type: "dirty";
|
|
55
|
+
payload: {
|
|
56
|
+
dirty: boolean;
|
|
57
|
+
};
|
|
58
|
+
} | {
|
|
59
|
+
v: 1;
|
|
60
|
+
type: "submit-result";
|
|
61
|
+
payload: SubmitResult;
|
|
62
|
+
} | {
|
|
63
|
+
v: 1;
|
|
64
|
+
type: "request-cancel";
|
|
65
|
+
payload: Record<string, never>;
|
|
66
|
+
};
|
|
67
|
+
export type IframeParentMessage = {
|
|
68
|
+
v: 1;
|
|
69
|
+
type: "init";
|
|
70
|
+
payload: {
|
|
71
|
+
instanceContext?: InstanceContext;
|
|
72
|
+
theme?: IframeTheme;
|
|
73
|
+
locale?: string;
|
|
74
|
+
launchContext?: LaunchContext;
|
|
75
|
+
};
|
|
76
|
+
} | {
|
|
77
|
+
v: 1;
|
|
78
|
+
type: "submit";
|
|
79
|
+
payload: Record<string, never>;
|
|
80
|
+
} | {
|
|
81
|
+
v: 1;
|
|
82
|
+
type: "cancel";
|
|
83
|
+
payload: Record<string, never>;
|
|
84
|
+
} | {
|
|
85
|
+
v: 1;
|
|
86
|
+
type: "theme-change";
|
|
87
|
+
payload: {
|
|
88
|
+
theme: IframeTheme;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
export declare function parseParentMessage(data: unknown): IframeParentMessage | null;
|
|
92
|
+
export declare function parseChildMessage(data: unknown): IframeChildMessage | null;
|
|
93
|
+
export type IframeWindowLike = {
|
|
94
|
+
parent: {
|
|
95
|
+
postMessage(message: unknown, targetOrigin: string): void;
|
|
96
|
+
};
|
|
97
|
+
addEventListener(type: "message", listener: (event: {
|
|
98
|
+
data: unknown;
|
|
99
|
+
}) => void): void;
|
|
100
|
+
removeEventListener(type: "message", listener: (event: {
|
|
101
|
+
data: unknown;
|
|
102
|
+
}) => void): void;
|
|
103
|
+
document: {
|
|
104
|
+
body: {
|
|
105
|
+
scrollHeight: number;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
export declare const isEmbedded: (win?: IframeWindowLike) => boolean;
|
|
110
|
+
export declare const sendToParent: (msg: IframeChildMessage, win?: IframeWindowLike) => void;
|
|
111
|
+
export declare const onParentMessage: (handler: (msg: IframeParentMessage) => void, win?: IframeWindowLike) => (() => void);
|
|
112
|
+
export declare const reportHeight: (win?: IframeWindowLike) => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The runner<->product iframe postMessage contract (v1), single-sourced for
|
|
2
|
+
// BOTH sides: the runner's ProductIframe component builds IframeParentMessage
|
|
3
|
+
// values, and a product's config screen imports the child-side helpers below
|
|
4
|
+
// instead of hand-copying them from the iframe-integration doc.
|
|
5
|
+
//
|
|
6
|
+
// Standalone usage (product opened outside the runner): window.parent ===
|
|
7
|
+
// window, so every send becomes a no-op.
|
|
8
|
+
export const IFRAME_PROTOCOL_VERSION = 1;
|
|
9
|
+
// Envelope guard shared by both directions: any v1 object with a string type
|
|
10
|
+
// passes (matching the product copies' onParentMessage filter); per-field
|
|
11
|
+
// payload validation stays with the consumer, which knows its own defaults.
|
|
12
|
+
function isEnvelope(data) {
|
|
13
|
+
return (data !== null &&
|
|
14
|
+
typeof data === "object" &&
|
|
15
|
+
data.v === IFRAME_PROTOCOL_VERSION &&
|
|
16
|
+
typeof data.type === "string");
|
|
17
|
+
}
|
|
18
|
+
export function parseParentMessage(data) {
|
|
19
|
+
return isEnvelope(data) ? data : null;
|
|
20
|
+
}
|
|
21
|
+
export function parseChildMessage(data) {
|
|
22
|
+
return isEnvelope(data) ? data : null;
|
|
23
|
+
}
|
|
24
|
+
// globalThis, not window: node-side consumers (dev-kit, backend) pull this
|
|
25
|
+
// module in via the browser barrel and typecheck without the DOM lib.
|
|
26
|
+
const realWindow = () => globalThis.window;
|
|
27
|
+
export const isEmbedded = (win = realWindow()) => win.parent !== win;
|
|
28
|
+
export const sendToParent = (msg, win = realWindow()) => {
|
|
29
|
+
if (!isEmbedded(win))
|
|
30
|
+
return;
|
|
31
|
+
win.parent.postMessage(msg, "*");
|
|
32
|
+
};
|
|
33
|
+
export const onParentMessage = (handler, win = realWindow()) => {
|
|
34
|
+
const listener = (event) => {
|
|
35
|
+
const msg = parseParentMessage(event.data);
|
|
36
|
+
if (msg)
|
|
37
|
+
handler(msg);
|
|
38
|
+
};
|
|
39
|
+
win.addEventListener("message", listener);
|
|
40
|
+
return () => win.removeEventListener("message", listener);
|
|
41
|
+
};
|
|
42
|
+
export const reportHeight = (win = realWindow()) => {
|
|
43
|
+
sendToParent({ v: 1, type: "resize", payload: { height: win.document.body.scrollHeight } }, win);
|
|
44
|
+
};
|
package/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./capabilities-router.js";
|
|
|
3
3
|
export * from "./cjs-interop.js";
|
|
4
4
|
export * from "./dev-url.js";
|
|
5
5
|
export * from "./docker-runner.js";
|
|
6
|
+
export * from "./http-proxy.js";
|
|
6
7
|
export * from "./license-registration.js";
|
|
7
8
|
export * from "./license-stager.js";
|
|
8
9
|
export * from "./license-v2.js";
|
|
@@ -13,3 +14,5 @@ export * from "./product-health-monitor.js";
|
|
|
13
14
|
export * from "./product-service.js";
|
|
14
15
|
export * from "./product-template-materials.js";
|
|
15
16
|
export * from "./proxy-middleware.js";
|
|
17
|
+
export * from "./route-error.js";
|
|
18
|
+
export * from "./sse.js";
|
package/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./capabilities-router.js";
|
|
|
5
5
|
export * from "./cjs-interop.js";
|
|
6
6
|
export * from "./dev-url.js";
|
|
7
7
|
export * from "./docker-runner.js";
|
|
8
|
+
export * from "./http-proxy.js";
|
|
8
9
|
export * from "./license-registration.js";
|
|
9
10
|
export * from "./license-stager.js";
|
|
10
11
|
export * from "./license-v2.js";
|
|
@@ -15,3 +16,5 @@ export * from "./product-health-monitor.js";
|
|
|
15
16
|
export * from "./product-service.js";
|
|
16
17
|
export * from "./product-template-materials.js";
|
|
17
18
|
export * from "./proxy-middleware.js";
|
|
19
|
+
export * from "./route-error.js";
|
|
20
|
+
export * from "./sse.js";
|
package/manifest-schema.d.ts
CHANGED
|
@@ -22,8 +22,8 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
22
22
|
healthCheckPath: z.ZodDefault<z.ZodString>;
|
|
23
23
|
productMcpPath: z.ZodOptional<z.ZodString>;
|
|
24
24
|
}, z.core.$strip>;
|
|
25
|
-
ui: z.ZodObject<{
|
|
26
|
-
configScreenUrl: z.ZodString
|
|
25
|
+
ui: z.ZodDefault<z.ZodObject<{
|
|
26
|
+
configScreenUrl: z.ZodOptional<z.ZodString>;
|
|
27
27
|
instanceConfigScreenUrl: z.ZodOptional<z.ZodString>;
|
|
28
28
|
sidebarEntries: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
29
29
|
label: z.ZodString;
|
|
@@ -39,8 +39,8 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
39
39
|
label: z.ZodString;
|
|
40
40
|
configScreenUrl: z.ZodString;
|
|
41
41
|
}, z.core.$strip>>>;
|
|
42
|
-
}, z.core.$strip
|
|
43
|
-
cli: z.ZodObject<{
|
|
42
|
+
}, z.core.$strip>>;
|
|
43
|
+
cli: z.ZodDefault<z.ZodObject<{
|
|
44
44
|
subcommandTree: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
45
45
|
name: z.ZodString;
|
|
46
46
|
description: z.ZodString;
|
|
@@ -53,6 +53,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
53
53
|
number: "number";
|
|
54
54
|
boolean: "boolean";
|
|
55
55
|
}>>;
|
|
56
|
+
choices: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
57
|
}, z.core.$strip>>>;
|
|
57
58
|
subcommands: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
58
59
|
name: z.ZodString;
|
|
@@ -66,6 +67,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
66
67
|
number: "number";
|
|
67
68
|
boolean: "boolean";
|
|
68
69
|
}>>;
|
|
70
|
+
choices: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
69
71
|
}, z.core.$strip>>>;
|
|
70
72
|
request: z.ZodOptional<z.ZodObject<{
|
|
71
73
|
method: z.ZodEnum<{
|
|
@@ -87,7 +89,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
87
89
|
path: z.ZodString;
|
|
88
90
|
}, z.core.$strip>>;
|
|
89
91
|
}, z.core.$strip>>>;
|
|
90
|
-
}, z.core.$strip
|
|
92
|
+
}, z.core.$strip>>;
|
|
91
93
|
targets: z.ZodArray<z.ZodEnum<{
|
|
92
94
|
"norsk-ctl": "norsk-ctl";
|
|
93
95
|
"docker-compose": "docker-compose";
|
|
@@ -110,4 +112,9 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
110
112
|
}, z.core.$strip>>;
|
|
111
113
|
}, z.core.$strip>;
|
|
112
114
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
115
|
+
/** Producer-facing shape: everything the reader defaults is optional, so a
|
|
116
|
+
* product's buildManifest() sends only what it actually declares instead of
|
|
117
|
+
* `sidebarEntries: []`-style boilerplate. The parsed {@link Manifest} keeps
|
|
118
|
+
* those fields present, so runner code needs no null-guards. */
|
|
119
|
+
export type ManifestInput = z.input<typeof ManifestSchema>;
|
|
113
120
|
export {};
|
package/manifest-schema.js
CHANGED
|
@@ -31,6 +31,9 @@ const CliFlagSchema = z.object({
|
|
|
31
31
|
description: z.string(),
|
|
32
32
|
required: z.boolean().optional(),
|
|
33
33
|
type: z.enum(["string", "number", "boolean"]).optional(),
|
|
34
|
+
choices: z.array(z.string()).optional().meta({
|
|
35
|
+
description: "Allowed values for the flag. The runner's CLI rejects anything else and lists them in help output. Omit for free-form flags.",
|
|
36
|
+
}),
|
|
34
37
|
});
|
|
35
38
|
/** How a CLI leaf forwards to the product's HTTP surface. The runner registers
|
|
36
39
|
* a yargs command that issues `method` against `/products/<name><path>` (the
|
|
@@ -106,8 +109,11 @@ export const ManifestSchema = z.object({
|
|
|
106
109
|
description: "Path on the product control-plane's HTTP surface where it serves an always-on, instance-independent MCP endpoint (e.g. plugin scaffolding). Proxied as `<product>_<tool>`. Distinct from the per-instance `mcpPath`. Omit for products without one.",
|
|
107
110
|
}),
|
|
108
111
|
}),
|
|
109
|
-
ui: z
|
|
110
|
-
|
|
112
|
+
ui: z
|
|
113
|
+
.object({
|
|
114
|
+
configScreenUrl: z.string().optional().meta({
|
|
115
|
+
description: "URL (on the product's HTTP surface) of the configure screen the runner iframes to build product templates. Omit for backend-only products with no configure UI — the runner skips its registration probe and the UI offers a config-free build path instead of an iframe.",
|
|
116
|
+
}),
|
|
111
117
|
// Optional rich instance-launch config screen. When present, the runner's
|
|
112
118
|
// launch flow probes it (a GET carrying product-template context); the
|
|
113
119
|
// product returns 200 to have the runner iframe it in place of the
|
|
@@ -121,10 +127,13 @@ export const ManifestSchema = z.object({
|
|
|
121
127
|
sidebarEntries: z.array(SidebarEntrySchema).default([]),
|
|
122
128
|
dashboardWidgets: z.array(DashboardWidgetSchema).default([]),
|
|
123
129
|
productTemplateActions: z.array(ProductTemplateActionSchema).default([]),
|
|
124
|
-
})
|
|
125
|
-
|
|
130
|
+
})
|
|
131
|
+
.default({ sidebarEntries: [], dashboardWidgets: [], productTemplateActions: [] }),
|
|
132
|
+
cli: z
|
|
133
|
+
.object({
|
|
126
134
|
subcommandTree: z.array(CliCommandSchema).default([]),
|
|
127
|
-
})
|
|
135
|
+
})
|
|
136
|
+
.default({ subcommandTree: [] }),
|
|
128
137
|
targets: z.array(TargetSchema),
|
|
129
138
|
components: z.array(ComponentDescSchema).default([]),
|
|
130
139
|
runtime: RuntimeHintsSchema,
|
package/manifest-seed.d.ts
CHANGED
|
@@ -26,3 +26,5 @@ export declare const SeedSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
|
26
26
|
}, z.core.$strict>>;
|
|
27
27
|
export type ManifestSeed = z.infer<typeof SeedSchema>;
|
|
28
28
|
export declare function parseManifestSeed(raw: unknown): Result<ManifestSeed, string>;
|
|
29
|
+
export declare const bareTag: (ref: string) => string;
|
|
30
|
+
export declare const repoOf: (ref: string) => string;
|
package/manifest-seed.js
CHANGED
|
@@ -38,3 +38,8 @@ export function parseManifestSeed(raw) {
|
|
|
38
38
|
const parsed = SeedSchema.safeParse(raw);
|
|
39
39
|
return parsed.success ? ok(parsed.data) : err(z.prettifyError(parsed.error));
|
|
40
40
|
}
|
|
41
|
+
// The seed carries FULL repo:tag refs; every product's version.ts splits them
|
|
42
|
+
// into the uniform MEDIA_*/STUDIO_* vocabulary (RFC 0001 §5). Split on the
|
|
43
|
+
// LAST colon so registry:port refs keep the port on the repo side.
|
|
44
|
+
export const bareTag = (ref) => ref.slice(ref.lastIndexOf(":") + 1);
|
|
45
|
+
export const repoOf = (ref) => ref.slice(0, ref.lastIndexOf(":"));
|
package/package.json
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* path (used by mgr — name validation + bytes persistence).
|
|
6
6
|
*/
|
|
7
7
|
export declare class ProductTemplateError extends Error {
|
|
8
|
-
code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE";
|
|
9
|
-
constructor(code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE", message: string);
|
|
8
|
+
code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE" | "PRODUCT_MISMATCH";
|
|
9
|
+
constructor(code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE" | "PRODUCT_MISMATCH", message: string);
|
|
10
10
|
}
|
package/proxy-middleware.js
CHANGED
|
@@ -1,48 +1,11 @@
|
|
|
1
1
|
import { logger } from "@norskvideo/ctl-foundation";
|
|
2
|
-
|
|
3
|
-
"connection",
|
|
4
|
-
"keep-alive",
|
|
5
|
-
"proxy-authenticate",
|
|
6
|
-
"proxy-authorization",
|
|
7
|
-
"te",
|
|
8
|
-
"trailers",
|
|
9
|
-
"transfer-encoding",
|
|
10
|
-
"upgrade",
|
|
11
|
-
"host",
|
|
12
|
-
"content-length",
|
|
13
|
-
]);
|
|
2
|
+
import { buildForwardHeaders, forwardUpstreamResponse, readRequestBody } from "./http-proxy.js";
|
|
14
3
|
function targetBaseUrl(reg) {
|
|
15
4
|
if (reg.spec.kind === "container") {
|
|
16
5
|
return reg.port !== undefined ? `http://127.0.0.1:${reg.port}` : null;
|
|
17
6
|
}
|
|
18
7
|
return reg.spec.url.replace(/\/$/, "");
|
|
19
8
|
}
|
|
20
|
-
function buildOutgoingHeaders(req) {
|
|
21
|
-
const out = {};
|
|
22
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
23
|
-
if (HOP_BY_HOP.has(key.toLowerCase()))
|
|
24
|
-
continue;
|
|
25
|
-
if (value === undefined)
|
|
26
|
-
continue;
|
|
27
|
-
out[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
28
|
-
}
|
|
29
|
-
return out;
|
|
30
|
-
}
|
|
31
|
-
async function readRequestBody(req) {
|
|
32
|
-
if (req.method === "GET" || req.method === "HEAD")
|
|
33
|
-
return undefined;
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
const chunks = [];
|
|
36
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
37
|
-
req.on("end", () => {
|
|
38
|
-
if (!chunks.length)
|
|
39
|
-
return resolve(undefined);
|
|
40
|
-
const buf = Buffer.concat(chunks);
|
|
41
|
-
resolve(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
|
42
|
-
});
|
|
43
|
-
req.on("error", reject);
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
9
|
export function createProductProxyMiddleware(svc) {
|
|
47
10
|
return async (req, res, next) => {
|
|
48
11
|
const name = req.params.name;
|
|
@@ -60,7 +23,7 @@ export function createProductProxyMiddleware(svc) {
|
|
|
60
23
|
return;
|
|
61
24
|
}
|
|
62
25
|
const targetUrl = `${base}${req.url}`;
|
|
63
|
-
const headers =
|
|
26
|
+
const headers = buildForwardHeaders(req);
|
|
64
27
|
const body = await readRequestBody(req);
|
|
65
28
|
let upstream;
|
|
66
29
|
try {
|
|
@@ -76,37 +39,13 @@ export function createProductProxyMiddleware(svc) {
|
|
|
76
39
|
res.status(502).json({ status: "error", message: `upstream fetch failed: ${String(e)}` });
|
|
77
40
|
return;
|
|
78
41
|
}
|
|
79
|
-
res.status(upstream.status);
|
|
80
42
|
const productPrefix = `/products/${encodeURIComponent(name)}`;
|
|
81
|
-
|
|
82
|
-
const lower = key.toLowerCase();
|
|
83
|
-
if (HOP_BY_HOP.has(lower))
|
|
84
|
-
return;
|
|
43
|
+
await forwardUpstreamResponse(res, upstream, {
|
|
85
44
|
// Rewrite root-absolute redirect Locations so 3xx responses don't
|
|
86
45
|
// escape the product prefix and land on the runner SPA. Relative
|
|
87
46
|
// (non-/-prefixed) and full-URL Locations pass through unchanged.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
res.setHeader(key, value);
|
|
47
|
+
rewriteHeader: (key, value) => key === "location" && value.startsWith("/") && !value.startsWith("//") ? `${productPrefix}${value}` : value,
|
|
48
|
+
onStreamError: (e) => logger.warn(`Proxy stream from ${targetUrl} broke: ${String(e)}`),
|
|
93
49
|
});
|
|
94
|
-
if (!upstream.body) {
|
|
95
|
-
res.end();
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const reader = upstream.body.getReader();
|
|
99
|
-
try {
|
|
100
|
-
while (true) {
|
|
101
|
-
const { done, value } = await reader.read();
|
|
102
|
-
if (done)
|
|
103
|
-
break;
|
|
104
|
-
res.write(value);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
catch (e) {
|
|
108
|
-
logger.warn(`Proxy stream from ${targetUrl} broke: ${String(e)}`);
|
|
109
|
-
}
|
|
110
|
-
res.end();
|
|
111
50
|
};
|
|
112
51
|
}
|
package/route-error.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Response } from "express";
|
|
2
|
+
export type RouteErrorClassification = {
|
|
3
|
+
status: number;
|
|
4
|
+
error: string;
|
|
5
|
+
};
|
|
6
|
+
export type RouteErrorClassifier = (err: unknown) => RouteErrorClassification | undefined;
|
|
7
|
+
export declare function sendRouteError(res: Response, err: unknown, fallbackError: string, classify?: RouteErrorClassifier): void;
|
|
8
|
+
/** Run a route body, mapping any throw or rejection to the error shape above.
|
|
9
|
+
* `fallbackError` is the route's stable failure label (e.g.
|
|
10
|
+
* "product-template generation failed"); `classify` upgrades known error
|
|
11
|
+
* classes to their own status + label (e.g. a seed error to a 400). */
|
|
12
|
+
export declare function guardRoute(res: Response, fallbackError: string, fn: () => void | Promise<void>, classify?: RouteErrorClassifier): void;
|
package/route-error.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function sendRouteError(res, err, fallbackError, classify) {
|
|
2
|
+
if (res.headersSent) {
|
|
3
|
+
res.end();
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
const classified = classify?.(err);
|
|
7
|
+
res.status(classified?.status ?? 500).json({
|
|
8
|
+
error: classified?.error ?? fallbackError,
|
|
9
|
+
message: err instanceof Error ? err.message : String(err),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** Run a route body, mapping any throw or rejection to the error shape above.
|
|
13
|
+
* `fallbackError` is the route's stable failure label (e.g.
|
|
14
|
+
* "product-template generation failed"); `classify` upgrades known error
|
|
15
|
+
* classes to their own status + label (e.g. a seed error to a 400). */
|
|
16
|
+
export function guardRoute(res, fallbackError, fn, classify) {
|
|
17
|
+
try {
|
|
18
|
+
const out = fn();
|
|
19
|
+
if (out instanceof Promise) {
|
|
20
|
+
out.catch((err) => sendRouteError(res, err, fallbackError, classify));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
sendRouteError(res, err, fallbackError, classify);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/sse.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "express";
|
|
2
|
+
export type PollingSseOptions<T> = {
|
|
3
|
+
/** Produce one snapshot for one client tick. Receives the request so the
|
|
4
|
+
* upstream can be addressed per-request (e.g. ?studioBase=). A throw or
|
|
5
|
+
* rejection is swallowed; the next tick retries. */
|
|
6
|
+
snapshot: (req: Request) => T | Promise<T>;
|
|
7
|
+
/** Time between frames. Defaults to 1s (funke's STREAM_INTERVAL_MS). */
|
|
8
|
+
intervalMs?: number;
|
|
9
|
+
};
|
|
10
|
+
/** SSE feed: emit a `data: <json>` frame immediately, then every intervalMs
|
|
11
|
+
* until the client disconnects. */
|
|
12
|
+
export declare function createPollingSseHandler<T>(opts: PollingSseOptions<T>): RequestHandler;
|
package/sse.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Polling Server-Sent-Events bridge, lifted from funke's /api/metrics/stream.
|
|
2
|
+
//
|
|
3
|
+
// Why SSE and not a proxied WebSocket: bun's node:http "upgrade" socket is not
|
|
4
|
+
// writable back to the client, so neither a hand-rolled raw-socket bridge nor
|
|
5
|
+
// http-proxy's .ws() can forward the 101 in-process. A product backend that
|
|
6
|
+
// needs an upstream WS feed (e.g. Studio's /live/firehose) therefore consumes
|
|
7
|
+
// it as a CLIENT and re-emits snapshots same-origin over SSE — a plain
|
|
8
|
+
// long-lived GET that also rides existing /api/* reverse-proxy locations with
|
|
9
|
+
// no Upgrade handshake and no new dependency.
|
|
10
|
+
/** SSE feed: emit a `data: <json>` frame immediately, then every intervalMs
|
|
11
|
+
* until the client disconnects. */
|
|
12
|
+
export function createPollingSseHandler(opts) {
|
|
13
|
+
const intervalMs = opts.intervalMs ?? 1000;
|
|
14
|
+
return (req, res) => {
|
|
15
|
+
res.set({
|
|
16
|
+
"Content-Type": "text/event-stream",
|
|
17
|
+
"Cache-Control": "no-cache, no-transform",
|
|
18
|
+
Connection: "keep-alive",
|
|
19
|
+
});
|
|
20
|
+
res.flushHeaders?.();
|
|
21
|
+
let closed = false;
|
|
22
|
+
const tick = async () => {
|
|
23
|
+
if (closed)
|
|
24
|
+
return;
|
|
25
|
+
try {
|
|
26
|
+
const value = await opts.snapshot(req);
|
|
27
|
+
if (!closed)
|
|
28
|
+
res.write(`data: ${JSON.stringify(value)}\n\n`);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Transient upstream failures are swallowed; the next tick retries.
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
void tick();
|
|
35
|
+
const timer = setInterval(() => void tick(), intervalMs);
|
|
36
|
+
req.on("close", () => {
|
|
37
|
+
closed = true;
|
|
38
|
+
clearInterval(timer);
|
|
39
|
+
res.end();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}
|
package/workflow.d.ts
CHANGED
|
@@ -39,6 +39,14 @@ export declare const VIDEO_ONLY: SubscriptionStreams;
|
|
|
39
39
|
export declare const AUDIO_ONLY: SubscriptionStreams;
|
|
40
40
|
export declare const FIRST_VIDEO: SubscriptionStreams;
|
|
41
41
|
export declare const FIRST_AUDIO: SubscriptionStreams;
|
|
42
|
+
export type ComponentAdder<Handle> = {
|
|
43
|
+
addNode(identifier: string, config: never): Handle;
|
|
44
|
+
};
|
|
45
|
+
export declare function addComponent<Handle, C extends object>(builder: ComponentAdder<Handle>, component: WorkflowComponent<C>): Handle;
|
|
46
|
+
export type DocumentSource = {
|
|
47
|
+
toDocument(): unknown;
|
|
48
|
+
};
|
|
49
|
+
export declare function toWorkflowDoc<D extends WorkflowDoc = WorkflowDoc>(builder: DocumentSource): D;
|
|
42
50
|
export type SyntheticNodeInfo = {
|
|
43
51
|
identifier: string;
|
|
44
52
|
subscription: {
|
|
@@ -57,4 +65,36 @@ export type SyntheticNodeInfo = {
|
|
|
57
65
|
};
|
|
58
66
|
};
|
|
59
67
|
};
|
|
68
|
+
export type ComponentStubMedia = "video" | "audio" | "subtitle" | "ancillary" | "playlist";
|
|
69
|
+
export type ComponentStubSpec = {
|
|
70
|
+
identifier: string;
|
|
71
|
+
accepts?: ComponentStubMedia[];
|
|
72
|
+
produces?: ComponentStubMedia[] | "passthrough";
|
|
73
|
+
acceptsTransient?: boolean;
|
|
74
|
+
validateConfig?: (config: Record<string, unknown>) => string[] | undefined;
|
|
75
|
+
};
|
|
76
|
+
type StubMediaFlags = {
|
|
77
|
+
[M in ComponentStubMedia]?: true;
|
|
78
|
+
};
|
|
79
|
+
export type StubNodeInfo = {
|
|
80
|
+
identifier: string;
|
|
81
|
+
subscription: {
|
|
82
|
+
accepts?: {
|
|
83
|
+
type: "simple-stream";
|
|
84
|
+
acceptsTransient: boolean;
|
|
85
|
+
} & StubMediaFlags;
|
|
86
|
+
produces?: ({
|
|
87
|
+
type: "simple-stream";
|
|
88
|
+
} & StubMediaFlags) | {
|
|
89
|
+
type: "dynamic-streams";
|
|
90
|
+
streams: <S>(cfg: unknown, inputStreams: S) => S;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
validateConfig?: (config: Record<string, unknown>) => string[] | undefined;
|
|
94
|
+
};
|
|
95
|
+
export declare function stubNodeInfo<T = StubNodeInfo>(spec: ComponentStubSpec): T;
|
|
96
|
+
export declare function stubbedLibrary<Info = StubNodeInfo>(find: (identifier: string) => Info | undefined, stubs: ComponentStubSpec[]): {
|
|
97
|
+
find(identifier: string): Info | undefined;
|
|
98
|
+
};
|
|
60
99
|
export declare function syntheticInfo<T = SyntheticNodeInfo>(identifier: string): T;
|
|
100
|
+
export {};
|
package/workflow.js
CHANGED
|
@@ -26,6 +26,76 @@ export const FIRST_AUDIO = {
|
|
|
26
26
|
type: "take-first-stream",
|
|
27
27
|
filter: [{ media: "audio" }],
|
|
28
28
|
};
|
|
29
|
+
// Typed `addNode` boundary: adds a factory-built component to a WorkflowBuilder
|
|
30
|
+
// with the factory's config typing intact, so composers need no `config as
|
|
31
|
+
// never` at the add boundary. Subscriptions are NOT applied — wiring goes
|
|
32
|
+
// through the builder's connect()/pick* helpers, exactly as every product's
|
|
33
|
+
// local add() helper behaved. The single cast below is the one audited erasure:
|
|
34
|
+
// the builder's `BaseConfig` constraint wants an index signature that interface-
|
|
35
|
+
// declared product configs may lack, while the real gates stay intact — the
|
|
36
|
+
// factory return type checks the config shape at construction, and
|
|
37
|
+
// builder.validate() runs each node's real validateConfig at compose time.
|
|
38
|
+
export function addComponent(builder, component) {
|
|
39
|
+
return builder.addNode(component.type, component.config);
|
|
40
|
+
}
|
|
41
|
+
// Typed `toDocument` boundary: the builder returns its all-optional YamlDocument
|
|
42
|
+
// shape, which products immediately re-assert as their WorkflowDoc (previously
|
|
43
|
+
// via `as unknown as WorkflowDoc` at every call site). Sound by construction:
|
|
44
|
+
// the builder always emits a components array, and each entry carries the
|
|
45
|
+
// type/config/subscriptions the composer added. Pass a product-extended doc
|
|
46
|
+
// type (e.g. probe's signed document) as `D` when the product adds fields.
|
|
47
|
+
export function toWorkflowDoc(builder) {
|
|
48
|
+
return builder.toDocument();
|
|
49
|
+
}
|
|
50
|
+
function mediaFlags(media) {
|
|
51
|
+
return Object.fromEntries(media.map((m) => [m, true]));
|
|
52
|
+
}
|
|
53
|
+
// A builder NodeInfo from a metadata-only stub declaration. Unlike
|
|
54
|
+
// syntheticInfo's accept-anything fallback, the resulting info carries the
|
|
55
|
+
// component's REAL media contract, so edges into and out of the stub
|
|
56
|
+
// stream-validate like any built-in (only its config stays unvalidated unless
|
|
57
|
+
// the spec supplies validateConfig).
|
|
58
|
+
export function stubNodeInfo(spec) {
|
|
59
|
+
const accepts = spec.accepts
|
|
60
|
+
? {
|
|
61
|
+
accepts: {
|
|
62
|
+
type: "simple-stream",
|
|
63
|
+
...mediaFlags(spec.accepts),
|
|
64
|
+
acceptsTransient: spec.acceptsTransient ?? true,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
: {};
|
|
68
|
+
const produces = spec.produces === "passthrough"
|
|
69
|
+
? {
|
|
70
|
+
produces: {
|
|
71
|
+
type: "dynamic-streams",
|
|
72
|
+
streams: (_cfg, inputStreams) => inputStreams,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: spec.produces && spec.produces.length > 0
|
|
76
|
+
? { produces: { type: "simple-stream", ...mediaFlags(spec.produces) } }
|
|
77
|
+
: {};
|
|
78
|
+
return {
|
|
79
|
+
identifier: spec.identifier,
|
|
80
|
+
subscription: { ...accepts, ...produces },
|
|
81
|
+
...(spec.validateConfig ? { validateConfig: spec.validateConfig } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// A ComponentLibrary that resolves the real library first and declared stubs
|
|
85
|
+
// second — and, crucially, NOTHING else: an identifier that is neither real nor
|
|
86
|
+
// declared stays unresolved, so the builder's addNode throws on it instead of
|
|
87
|
+
// silently composing against a permissive synthetic. This is what lets
|
|
88
|
+
// builder.validate() be a throwing gate fleet-wide — every node is either a
|
|
89
|
+
// real NodeInfo or an explicitly declared stub, and a typo fails the compose.
|
|
90
|
+
export function stubbedLibrary(find, stubs) {
|
|
91
|
+
const stubInfos = new Map();
|
|
92
|
+
for (const spec of stubs) {
|
|
93
|
+
if (stubInfos.has(spec.identifier))
|
|
94
|
+
throw new Error(`duplicate component stub: ${spec.identifier}`);
|
|
95
|
+
stubInfos.set(spec.identifier, stubNodeInfo(spec));
|
|
96
|
+
}
|
|
97
|
+
return { find: (identifier) => find(identifier) ?? stubInfos.get(identifier) };
|
|
98
|
+
}
|
|
29
99
|
// A permissive builder NodeInfo for an identifier the real component library
|
|
30
100
|
// does not know (a product's own component, or an alpha/custom node that lives
|
|
31
101
|
// only in the running studio image). It accepts any media and PASSES INPUT
|