@norskvideo/ctl-sdk 0.1.0
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/base.css +53 -0
- package/browser.d.ts +8 -0
- package/browser.js +11 -0
- package/capabilities-router.d.ts +9 -0
- package/capabilities-router.js +15 -0
- package/cjs-interop.d.ts +33 -0
- package/cjs-interop.js +61 -0
- package/components/ProductIframe.d.ts +37 -0
- package/components/ProductIframe.js +119 -0
- package/components/ProductTemplateBuildForm.d.ts +40 -0
- package/components/ProductTemplateBuildForm.js +81 -0
- package/components/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/components/ui-primitives.d.ts +22 -0
- package/components/ui-primitives.js +13 -0
- package/dev-url.d.ts +1 -0
- package/dev-url.js +14 -0
- package/docker-runner.d.ts +20 -0
- package/docker-runner.js +54 -0
- package/fonts/Geist-LICENSE.txt +92 -0
- package/fonts/Geist.woff2 +0 -0
- package/fonts/GeistMono.woff2 +0 -0
- package/fonts/README.md +21 -0
- package/fonts/STUDIO-FONT-SYNC.md +86 -0
- package/index.d.ts +15 -0
- package/index.js +17 -0
- package/license-registration.d.ts +52 -0
- package/license-registration.js +38 -0
- package/license-stager.d.ts +30 -0
- package/license-stager.js +118 -0
- package/license-v2.d.ts +107 -0
- package/license-v2.js +205 -0
- package/manifest-fetch.d.ts +29 -0
- package/manifest-fetch.js +100 -0
- package/manifest-router.d.ts +11 -0
- package/manifest-router.js +16 -0
- package/manifest-schema.d.ts +113 -0
- package/manifest-schema.js +135 -0
- package/manifest-seed.d.ts +28 -0
- package/manifest-seed.js +40 -0
- package/openapi-router.d.ts +12 -0
- package/openapi-router.js +21 -0
- package/package.json +46 -0
- package/parsing.d.ts +9 -0
- package/parsing.js +83 -0
- package/product-error.d.ts +4 -0
- package/product-error.js +8 -0
- package/product-health-monitor.d.ts +72 -0
- package/product-health-monitor.js +136 -0
- package/product-service.d.ts +118 -0
- package/product-service.js +340 -0
- package/product-template-error.d.ts +10 -0
- package/product-template-error.js +14 -0
- package/product-template-materials.d.ts +17 -0
- package/product-template-materials.js +51 -0
- package/product-template-parsing.d.ts +14 -0
- package/product-template-parsing.js +66 -0
- package/product-template-record.d.ts +45 -0
- package/product-template-record.js +1 -0
- package/product-types.d.ts +31 -0
- package/product-types.js +1 -0
- package/proxy-middleware.d.ts +7 -0
- package/proxy-middleware.js +112 -0
- package/runtime.d.ts +2 -0
- package/runtime.js +21 -0
- package/validate.d.ts +3 -0
- package/validate.js +22 -0
- package/workflow.d.ts +60 -0
- package/workflow.js +57 -0
package/base.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Base stylesheet shared by every product frontend. Products import this and
|
|
3
|
+
* state no font/theme/reset opinion of their own — so norsk-ctl, the product
|
|
4
|
+
* config UIs, and Studio all render identically.
|
|
5
|
+
*
|
|
6
|
+
* @import "@norskvideo/ctl-sdk/base.css";
|
|
7
|
+
*
|
|
8
|
+
* Geist Sans + Geist Mono (Vercel, SIL OFL 1.1 — see fonts/Geist-LICENSE.txt),
|
|
9
|
+
* vendored once here as single variable woff2 files so nothing pulls a font
|
|
10
|
+
* from a CDN and every surface renders the same glyphs. Norsk Studio must be
|
|
11
|
+
* moved onto the same Geist files to stay in sync (see fonts/README.md).
|
|
12
|
+
*/
|
|
13
|
+
@import "tailwindcss";
|
|
14
|
+
|
|
15
|
+
@font-face {
|
|
16
|
+
font-family: "Geist";
|
|
17
|
+
font-style: normal;
|
|
18
|
+
font-weight: 100 900; /* variable range → no faux bold */
|
|
19
|
+
font-display: swap;
|
|
20
|
+
src: url("./fonts/Geist.woff2") format("woff2-variations"),
|
|
21
|
+
url("./fonts/Geist.woff2") format("woff2");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@font-face {
|
|
25
|
+
font-family: "Geist Mono";
|
|
26
|
+
font-style: normal;
|
|
27
|
+
font-weight: 100 900;
|
|
28
|
+
font-display: swap;
|
|
29
|
+
src: url("./fonts/GeistMono.woff2") format("woff2-variations"),
|
|
30
|
+
url("./fonts/GeistMono.woff2") format("woff2");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@theme {
|
|
34
|
+
--color-zinc-850: #1f1f23;
|
|
35
|
+
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
|
36
|
+
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
* {
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
::-webkit-scrollbar {
|
|
44
|
+
width: 6px;
|
|
45
|
+
height: 6px;
|
|
46
|
+
}
|
|
47
|
+
::-webkit-scrollbar-track {
|
|
48
|
+
background: #18181b;
|
|
49
|
+
}
|
|
50
|
+
::-webkit-scrollbar-thumb {
|
|
51
|
+
background: #3f3f46;
|
|
52
|
+
border-radius: 3px;
|
|
53
|
+
}
|
package/browser.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./manifest-schema.js";
|
|
2
|
+
export * from "./parsing.js";
|
|
3
|
+
export * from "./product-error.js";
|
|
4
|
+
export * from "./product-template-error.js";
|
|
5
|
+
export * from "./product-template-parsing.js";
|
|
6
|
+
export * from "./product-template-record.js";
|
|
7
|
+
export * from "./product-types.js";
|
|
8
|
+
export * from "./validate.js";
|
package/browser.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Browser-safe surface: types, schemas, parsers, validators.
|
|
2
|
+
// Anything with Node-only deps (fs, child_process, express) lives in the
|
|
3
|
+
// default entry only.
|
|
4
|
+
export * from "./manifest-schema.js";
|
|
5
|
+
export * from "./parsing.js";
|
|
6
|
+
export * from "./product-error.js";
|
|
7
|
+
export * from "./product-template-error.js";
|
|
8
|
+
export * from "./product-template-parsing.js";
|
|
9
|
+
export * from "./product-template-record.js";
|
|
10
|
+
export * from "./product-types.js";
|
|
11
|
+
export * from "./validate.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Serves a product's self-describing capabilities at `GET /capabilities`,
|
|
4
|
+
* relative to wherever the router is mounted (products mount it under `/api`).
|
|
5
|
+
*
|
|
6
|
+
* Generic over `T` so each product's distinct capabilities type flows through
|
|
7
|
+
* unchanged — the factory carries no product knowledge and no flags.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createCapabilitiesRouter<T>(buildCapabilities: () => T): Router;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Serves a product's self-describing capabilities at `GET /capabilities`,
|
|
4
|
+
* relative to wherever the router is mounted (products mount it under `/api`).
|
|
5
|
+
*
|
|
6
|
+
* Generic over `T` so each product's distinct capabilities type flows through
|
|
7
|
+
* unchanged — the factory carries no product knowledge and no flags.
|
|
8
|
+
*/
|
|
9
|
+
export function createCapabilitiesRouter(buildCapabilities) {
|
|
10
|
+
const router = Router();
|
|
11
|
+
router.get("/capabilities", (_req, res) => {
|
|
12
|
+
res.json(buildCapabilities());
|
|
13
|
+
});
|
|
14
|
+
return router;
|
|
15
|
+
}
|
package/cjs-interop.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interop for the TypeScript-compiled CommonJS modules the Norsk Studio
|
|
3
|
+
* libraries ship (`exports.default = fn` plus an `__esModule` marker).
|
|
4
|
+
*
|
|
5
|
+
* How such a module reaches an ESM importer depends on WHO loaded it, and the
|
|
6
|
+
* shapes are not interchangeable:
|
|
7
|
+
*
|
|
8
|
+
* - Left external and required at runtime by bun → `.default` is the function.
|
|
9
|
+
* - Bundled by `bun build` → the emitted helper is
|
|
10
|
+
* `__toESM(require_mod(), /* isNodeMode *\/ 1)`, and that helper reads
|
|
11
|
+
* `isNodeMode || !mod || !mod.__esModule` — short-circuiting on isNodeMode
|
|
12
|
+
* before it ever tests `__esModule`. So `default` is set to the entire
|
|
13
|
+
* `module.exports`, and the real function sits at `.default.default`.
|
|
14
|
+
*
|
|
15
|
+
* A plain `import fn from "..."` therefore works or breaks purely on whether
|
|
16
|
+
* the importing package's build marked the library external — an invisible
|
|
17
|
+
* coupling between a bundler flag and a runtime call. Probe's product-template
|
|
18
|
+
* generation hit exactly this: `shared`'s build externalises the studio libs,
|
|
19
|
+
* `backend`'s does not, so the same source worked in one bundle and threw
|
|
20
|
+
* "import_info.default is not a function" in the other.
|
|
21
|
+
*
|
|
22
|
+
* Route every studio-library default import through here and the call site
|
|
23
|
+
* stops caring how it was built.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Extract the callable default export of a CommonJS-compiled module, whatever
|
|
27
|
+
* interop shape it arrived in.
|
|
28
|
+
*
|
|
29
|
+
* @param mod The imported module namespace, or the module itself.
|
|
30
|
+
* @param specifier What was imported — used in the error, so a failure names the
|
|
31
|
+
* module instead of a mangled bundler local like `import_info`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function callableDefault<T extends (...args: never[]) => unknown>(mod: unknown, specifier: string): T;
|
package/cjs-interop.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interop for the TypeScript-compiled CommonJS modules the Norsk Studio
|
|
3
|
+
* libraries ship (`exports.default = fn` plus an `__esModule` marker).
|
|
4
|
+
*
|
|
5
|
+
* How such a module reaches an ESM importer depends on WHO loaded it, and the
|
|
6
|
+
* shapes are not interchangeable:
|
|
7
|
+
*
|
|
8
|
+
* - Left external and required at runtime by bun → `.default` is the function.
|
|
9
|
+
* - Bundled by `bun build` → the emitted helper is
|
|
10
|
+
* `__toESM(require_mod(), /* isNodeMode *\/ 1)`, and that helper reads
|
|
11
|
+
* `isNodeMode || !mod || !mod.__esModule` — short-circuiting on isNodeMode
|
|
12
|
+
* before it ever tests `__esModule`. So `default` is set to the entire
|
|
13
|
+
* `module.exports`, and the real function sits at `.default.default`.
|
|
14
|
+
*
|
|
15
|
+
* A plain `import fn from "..."` therefore works or breaks purely on whether
|
|
16
|
+
* the importing package's build marked the library external — an invisible
|
|
17
|
+
* coupling between a bundler flag and a runtime call. Probe's product-template
|
|
18
|
+
* generation hit exactly this: `shared`'s build externalises the studio libs,
|
|
19
|
+
* `backend`'s does not, so the same source worked in one bundle and threw
|
|
20
|
+
* "import_info.default is not a function" in the other.
|
|
21
|
+
*
|
|
22
|
+
* Route every studio-library default import through here and the call site
|
|
23
|
+
* stops caring how it was built.
|
|
24
|
+
*/
|
|
25
|
+
/** How many `.default` levels to unwrap before giving up (guards cycles). */
|
|
26
|
+
const MAX_UNWRAP = 4;
|
|
27
|
+
/**
|
|
28
|
+
* Extract the callable default export of a CommonJS-compiled module, whatever
|
|
29
|
+
* interop shape it arrived in.
|
|
30
|
+
*
|
|
31
|
+
* @param mod The imported module namespace, or the module itself.
|
|
32
|
+
* @param specifier What was imported — used in the error, so a failure names the
|
|
33
|
+
* module instead of a mangled bundler local like `import_info`.
|
|
34
|
+
*/
|
|
35
|
+
export function callableDefault(mod, specifier) {
|
|
36
|
+
let candidate = mod;
|
|
37
|
+
for (let depth = 0; depth <= MAX_UNWRAP; depth++) {
|
|
38
|
+
if (typeof candidate === "function")
|
|
39
|
+
return candidate;
|
|
40
|
+
if (candidate === null || typeof candidate !== "object")
|
|
41
|
+
break;
|
|
42
|
+
const next = candidate.default;
|
|
43
|
+
if (next === undefined || next === candidate)
|
|
44
|
+
break;
|
|
45
|
+
candidate = next;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`'${specifier}' has no callable default export (found ${describe(mod)}). ` +
|
|
48
|
+
"A CommonJS module bundled by bun exposes module.exports as `default`, so the function " +
|
|
49
|
+
"can sit one level deeper — use callableDefault() rather than a plain default import.");
|
|
50
|
+
}
|
|
51
|
+
/** Describe what actually arrived, shallowly — enough to debug, no dumping. */
|
|
52
|
+
function describe(value) {
|
|
53
|
+
if (value === null)
|
|
54
|
+
return "null";
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return "undefined";
|
|
57
|
+
if (typeof value !== "object")
|
|
58
|
+
return typeof value;
|
|
59
|
+
const keys = Object.keys(value).slice(0, 8);
|
|
60
|
+
return `an object with keys [${keys.join(", ")}]`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type SubmitResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
formValues: Record<string, unknown>;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
export type ProductIframeHandle = {
|
|
9
|
+
submit: () => Promise<SubmitResult>;
|
|
10
|
+
};
|
|
11
|
+
export declare const ProductIframe: import("react").ForwardRefExoticComponent<{
|
|
12
|
+
productName: string;
|
|
13
|
+
configScreenUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* Fires when the iframe emits a `validity` postMessage. Parent forms
|
|
16
|
+
* use this to disable Save / Save & Launch while the form is invalid.
|
|
17
|
+
* Default before any validity message arrives is treat-as-valid —
|
|
18
|
+
* products that don't emit validity shouldn't be blocked.
|
|
19
|
+
*/
|
|
20
|
+
onValidityChange?: (valid: boolean, errors: string[]) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Fires when the iframe emits a `suggest-name` postMessage — a product
|
|
23
|
+
* template name derived from the product's current choices (e.g. template +
|
|
24
|
+
* release). The runner uses it as placeholder/prefill for its own
|
|
25
|
+
* product-template-name field while that field is untouched. Products that
|
|
26
|
+
* don't emit it leave the field's default behaviour unchanged.
|
|
27
|
+
*/
|
|
28
|
+
onSuggestName?: (name: string) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Optional product-template/launch context forwarded verbatim in the `init`
|
|
31
|
+
* payload (as `launchContext`). Used by the instance-launch config screen
|
|
32
|
+
* (not the template-configure screen) so the product can prefill against
|
|
33
|
+
* the product template being launched — e.g. its parameters and current
|
|
34
|
+
* defaults.
|
|
35
|
+
*/
|
|
36
|
+
launchContext?: Record<string, unknown>;
|
|
37
|
+
} & import("react").RefAttributes<ProductIframeHandle>>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
3
|
+
const PROTOCOL_VERSION = 1;
|
|
4
|
+
const SUBMIT_TIMEOUT_MS = 30_000;
|
|
5
|
+
function resolveSrc(productName, configScreenUrl) {
|
|
6
|
+
const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
|
|
7
|
+
return `/products/${encodeURIComponent(productName)}${path}`;
|
|
8
|
+
}
|
|
9
|
+
export const ProductIframe = forwardRef(function ProductIframe({ productName, configScreenUrl, onValidityChange, onSuggestName, launchContext }, ref) {
|
|
10
|
+
const iframeRef = useRef(null);
|
|
11
|
+
const [height, setHeight] = useState(420);
|
|
12
|
+
const [valid, setValid] = useState(true);
|
|
13
|
+
const [errors, setErrors] = useState(null);
|
|
14
|
+
const submitResolverRef = useRef(null);
|
|
15
|
+
// Ref-stored callback so the message listener (mount-only useEffect) can
|
|
16
|
+
// always reach the latest one without re-binding.
|
|
17
|
+
const onValidityChangeRef = useRef(onValidityChange);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
onValidityChangeRef.current = onValidityChange;
|
|
20
|
+
}, [onValidityChange]);
|
|
21
|
+
const onSuggestNameRef = useRef(onSuggestName);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
onSuggestNameRef.current = onSuggestName;
|
|
24
|
+
}, [onSuggestName]);
|
|
25
|
+
const src = resolveSrc(productName, configScreenUrl);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const onMessage = (event) => {
|
|
28
|
+
if (event.source !== iframeRef.current?.contentWindow)
|
|
29
|
+
return;
|
|
30
|
+
const data = event.data;
|
|
31
|
+
if (!data || typeof data !== "object")
|
|
32
|
+
return;
|
|
33
|
+
if (data.v !== PROTOCOL_VERSION)
|
|
34
|
+
return;
|
|
35
|
+
const payload = data.payload ?? {};
|
|
36
|
+
switch (data.type) {
|
|
37
|
+
case "ready":
|
|
38
|
+
case "resize":
|
|
39
|
+
if (typeof payload.height === "number" && payload.height > 0)
|
|
40
|
+
setHeight(payload.height);
|
|
41
|
+
break;
|
|
42
|
+
case "validity": {
|
|
43
|
+
const v = typeof payload.valid === "boolean" ? payload.valid : true;
|
|
44
|
+
const errs = Array.isArray(payload.errors) ? payload.errors : [];
|
|
45
|
+
setValid(v);
|
|
46
|
+
setErrors(errs.length ? errs : null);
|
|
47
|
+
onValidityChangeRef.current?.(v, errs);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "suggest-name": {
|
|
51
|
+
if (typeof payload.name === "string" && payload.name)
|
|
52
|
+
onSuggestNameRef.current?.(payload.name);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "submit-result": {
|
|
56
|
+
const resolve = submitResolverRef.current;
|
|
57
|
+
submitResolverRef.current = null;
|
|
58
|
+
if (!resolve)
|
|
59
|
+
break;
|
|
60
|
+
// New shape: { ok: true, formValues } / { ok: false, error }.
|
|
61
|
+
// Old shape (option-a): { ok: true, productTemplate, ... }. If we see
|
|
62
|
+
// the old shape, fall through with an empty formValues so the
|
|
63
|
+
// runner-side error message is the one the user sees.
|
|
64
|
+
if (payload?.ok === true && payload.formValues && typeof payload.formValues === "object") {
|
|
65
|
+
resolve({ ok: true, formValues: payload.formValues });
|
|
66
|
+
}
|
|
67
|
+
else if (payload?.ok === true) {
|
|
68
|
+
console.warn("[ProductIframe] product emitted legacy submit-result without `formValues`; " +
|
|
69
|
+
"update the product to send `{ ok: true, formValues: {...} }`");
|
|
70
|
+
resolve({ ok: false, error: "product returned legacy submit-result shape (no formValues)" });
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
resolve({ ok: false, error: typeof payload?.error === "string" ? payload.error : undefined });
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
// dirty / request-cancel / navigate intentionally ignored in v1
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener("message", onMessage);
|
|
81
|
+
return () => window.removeEventListener("message", onMessage);
|
|
82
|
+
}, []);
|
|
83
|
+
// Path prefix where the product is mounted on the runner's origin. The
|
|
84
|
+
// product's router (e.g. React Router's `basename`) needs this to resolve
|
|
85
|
+
// routes correctly when iframed; standalone mode can ignore it.
|
|
86
|
+
const basename = `/products/${encodeURIComponent(productName)}`;
|
|
87
|
+
// Ref so the mount-time onLoad closure always sends the latest context.
|
|
88
|
+
const launchContextRef = useRef(launchContext);
|
|
89
|
+
launchContextRef.current = launchContext;
|
|
90
|
+
const sendInit = useCallback(() => {
|
|
91
|
+
iframeRef.current?.contentWindow?.postMessage({
|
|
92
|
+
v: PROTOCOL_VERSION,
|
|
93
|
+
type: "init",
|
|
94
|
+
payload: {
|
|
95
|
+
instanceContext: { basename },
|
|
96
|
+
theme: "dark",
|
|
97
|
+
locale: "en",
|
|
98
|
+
...(launchContextRef.current ? { launchContext: launchContextRef.current } : {}),
|
|
99
|
+
},
|
|
100
|
+
}, "*");
|
|
101
|
+
}, [basename]);
|
|
102
|
+
useImperativeHandle(ref, () => ({
|
|
103
|
+
submit: () => new Promise((resolve) => {
|
|
104
|
+
if (!iframeRef.current?.contentWindow) {
|
|
105
|
+
resolve({ ok: false, error: "Iframe not loaded" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
submitResolverRef.current = resolve;
|
|
109
|
+
iframeRef.current.contentWindow.postMessage({ v: PROTOCOL_VERSION, type: "submit", payload: {} }, "*");
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
if (submitResolverRef.current === resolve) {
|
|
112
|
+
submitResolverRef.current = null;
|
|
113
|
+
resolve({ ok: false, error: `Iframe did not respond within ${SUBMIT_TIMEOUT_MS / 1000}s` });
|
|
114
|
+
}
|
|
115
|
+
}, SUBMIT_TIMEOUT_MS);
|
|
116
|
+
}),
|
|
117
|
+
}));
|
|
118
|
+
return (_jsxs("div", { className: "rounded border border-zinc-700 bg-zinc-950 overflow-hidden", children: [_jsx("iframe", { ref: iframeRef, src: src, title: `${productName} configuration`, onLoad: sendInit, className: "w-full block border-0", style: { height } }), !valid && (_jsx("p", { className: "text-xs text-amber-400 px-3 py-1.5 border-t border-zinc-800", children: errors?.length ? errors.join("; ") : "Form has validation errors." }))] }));
|
|
119
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configure-iframe form. Produces a *product template*, NOT a running instance.
|
|
3
|
+
* Two submit paths:
|
|
4
|
+
* - "Save" — caller persists the product template, then we close.
|
|
5
|
+
* - "Save & Launch…" — caller persists the product template and is expected to
|
|
6
|
+
* hand off to its launch flow.
|
|
7
|
+
*
|
|
8
|
+
* No runner-side mode toggle here: the iframe is pure product UX. Caller
|
|
9
|
+
* supplies the actual "save" implementation via `onSubmit`, so this works
|
|
10
|
+
* for both the runner (POST /api/product-templates/build → store on disk) and the
|
|
11
|
+
* manager (POST /api/instances → submit to the manager-daemon as
|
|
12
|
+
* ProductTemplateRef.inline_tar).
|
|
13
|
+
*/
|
|
14
|
+
export type ProductTemplateBuildRequest = {
|
|
15
|
+
productName: string;
|
|
16
|
+
productTemplateName: string;
|
|
17
|
+
formValues: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
export type ProductTemplateBuildOutcome<TProductTemplate> = {
|
|
20
|
+
ok: true;
|
|
21
|
+
productTemplate: TProductTemplate;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }: {
|
|
27
|
+
productName: string;
|
|
28
|
+
configScreenUrl: string | null;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Persists the product template. Implementation owns the wire protocol —
|
|
32
|
+
* runner stores on disk; manager submits to manager-daemon. Returns the
|
|
33
|
+
* resulting product-template record (consumer-defined shape) on success.
|
|
34
|
+
*/
|
|
35
|
+
onSubmit: (req: ProductTemplateBuildRequest) => Promise<ProductTemplateBuildOutcome<TProductTemplate>>;
|
|
36
|
+
/** Save-only: product template persisted, form closes. */
|
|
37
|
+
onSaved?: (productTemplate: TProductTemplate) => void;
|
|
38
|
+
/** Save & continue: caller transitions to its launch flow. */
|
|
39
|
+
onSavedAndLaunch?: (productTemplate: TProductTemplate) => void;
|
|
40
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import toast from "react-hot-toast";
|
|
5
|
+
import { validateInstanceId } from "../validate.js";
|
|
6
|
+
import { ProductIframe } from "./ProductIframe.js";
|
|
7
|
+
import { useUIPrimitives } from "./ui-primitives.js";
|
|
8
|
+
export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }) {
|
|
9
|
+
const { Button, Input, Label } = useUIPrimitives();
|
|
10
|
+
const [productTemplateName, setProductTemplateName] = useState("");
|
|
11
|
+
// The iframe can suggest a name derived from its current choices (#317).
|
|
12
|
+
// We prefill the field with it while the operator hasn't typed anything,
|
|
13
|
+
// so changing template/release keeps the suggestion live; once they edit
|
|
14
|
+
// the field, their value wins and we stop syncing.
|
|
15
|
+
const [suggestedName, setSuggestedName] = useState("");
|
|
16
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
17
|
+
const [submitting, setSubmitting] = useState(false);
|
|
18
|
+
// Treat-as-valid until the iframe says otherwise (per the v1 contract:
|
|
19
|
+
// products that don't care about validation aren't required to emit).
|
|
20
|
+
const [iframeValid, setIframeValid] = useState(true);
|
|
21
|
+
const iframeRef = useRef(null);
|
|
22
|
+
const handleSuggestName = (name) => {
|
|
23
|
+
setSuggestedName(name);
|
|
24
|
+
if (!nameTouched)
|
|
25
|
+
setProductTemplateName(name);
|
|
26
|
+
};
|
|
27
|
+
const nameValidation = productTemplateName ? validateInstanceId(productTemplateName) : null;
|
|
28
|
+
const productTemplateNameError = nameValidation?.status === "error" ? nameValidation.message : null;
|
|
29
|
+
const canSubmit = !!productTemplateName && !productTemplateNameError && !submitting && iframeValid;
|
|
30
|
+
// Internal helper. Returns the saved product template on success (so the
|
|
31
|
+
// caller can hand it off to its launch flow without waiting for a refetch),
|
|
32
|
+
// null on failure.
|
|
33
|
+
const saveProductTemplate = async () => {
|
|
34
|
+
if (!iframeRef.current) {
|
|
35
|
+
toast.error("Configuration not ready");
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const result = await iframeRef.current.submit();
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
toast.error(result.error ?? "Configuration submission failed");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const outcome = await onSubmit({ productName, productTemplateName, formValues: result.formValues });
|
|
44
|
+
if (!outcome.ok) {
|
|
45
|
+
toast.error(`Save product template failed: ${outcome.error}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return outcome.productTemplate;
|
|
49
|
+
};
|
|
50
|
+
const handleSave = async (e) => {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
setSubmitting(true);
|
|
53
|
+
try {
|
|
54
|
+
const pt = await saveProductTemplate();
|
|
55
|
+
if (!pt)
|
|
56
|
+
return;
|
|
57
|
+
toast.success(`Product template '${productTemplateName}' saved`);
|
|
58
|
+
onSaved?.(pt);
|
|
59
|
+
onClose();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
setSubmitting(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const handleSaveAndLaunch = async () => {
|
|
66
|
+
setSubmitting(true);
|
|
67
|
+
try {
|
|
68
|
+
const pt = await saveProductTemplate();
|
|
69
|
+
if (!pt)
|
|
70
|
+
return;
|
|
71
|
+
onSavedAndLaunch?.(pt);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
setSubmitting(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return (_jsxs("div", { className: "mb-6 rounded-lg border border-zinc-700 bg-zinc-900 p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsxs("div", { children: [_jsxs("h4", { className: "text-sm font-semibold text-zinc-100", children: ["Create ", productName, " product template"] }), _jsx("p", { className: "text-xs text-zinc-500 mt-0.5", children: "Saves a reusable product template. Launch instances from it later." })] }), _jsx("button", { type: "button", onClick: onClose, className: "text-zinc-500 hover:text-zinc-300", children: _jsx(X, { size: 14 }) })] }), _jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { children: "Product template name *" }), _jsx(Input, { value: productTemplateName, onChange: (e) => {
|
|
78
|
+
setNameTouched(true);
|
|
79
|
+
setProductTemplateName(e.target.value);
|
|
80
|
+
}, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ProductIframe, type ProductIframeHandle, type SubmitResult } from "./ProductIframe.js";
|
|
2
|
+
export { ProductTemplateBuildForm, type ProductTemplateBuildOutcome, type ProductTemplateBuildRequest, } from "./ProductTemplateBuildForm.js";
|
|
3
|
+
export { type ButtonProps, type ButtonSize, type ButtonVariant, type InputProps, type LabelProps, type UIPrimitives, UIPrimitivesProvider, useUIPrimitives, } from "./ui-primitives.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, type ComponentType, type InputHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
export type ButtonVariant = "default" | "ghost" | "danger" | "primary";
|
|
3
|
+
export type ButtonSize = "xs" | "sm" | "md";
|
|
4
|
+
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
5
|
+
variant?: ButtonVariant;
|
|
6
|
+
size?: ButtonSize;
|
|
7
|
+
};
|
|
8
|
+
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
|
9
|
+
export type LabelProps = {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
export interface UIPrimitives {
|
|
14
|
+
Button: ComponentType<ButtonProps>;
|
|
15
|
+
Input: ComponentType<InputProps>;
|
|
16
|
+
Label: ComponentType<LabelProps>;
|
|
17
|
+
}
|
|
18
|
+
export declare function UIPrimitivesProvider({ value, children }: {
|
|
19
|
+
value: UIPrimitives;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
22
|
+
export declare function useUIPrimitives(): UIPrimitives;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, } from "react";
|
|
3
|
+
const UIPrimitivesContext = createContext(null);
|
|
4
|
+
export function UIPrimitivesProvider({ value, children }) {
|
|
5
|
+
return _jsx(UIPrimitivesContext.Provider, { value: value, children: children });
|
|
6
|
+
}
|
|
7
|
+
export function useUIPrimitives() {
|
|
8
|
+
const ctx = useContext(UIPrimitivesContext);
|
|
9
|
+
if (!ctx) {
|
|
10
|
+
throw new Error("useUIPrimitives: no UIPrimitivesProvider in tree. Wrap your app root with <UIPrimitivesProvider value={...}>.");
|
|
11
|
+
}
|
|
12
|
+
return ctx;
|
|
13
|
+
}
|
package/dev-url.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateDevUrl(url: string): URL;
|
package/dev-url.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ProductError } from "./product-error.js";
|
|
2
|
+
export function validateDevUrl(url) {
|
|
3
|
+
let parsed;
|
|
4
|
+
try {
|
|
5
|
+
parsed = new URL(url);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new ProductError("DEV_URL_INVALID", `dev URL is not a valid URL: ${url}`);
|
|
9
|
+
}
|
|
10
|
+
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
|
11
|
+
throw new ProductError("DEV_URL_NOT_LOCALHOST", `dev URL must be localhost or 127.0.0.1 (v1), got ${parsed.hostname}`);
|
|
12
|
+
}
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Role label stamped on every product control-plane container. The daemon's
|
|
2
|
+
* infrastructure view discovers managed singletons by the presence of a
|
|
3
|
+
* `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
|
|
4
|
+
* value doubles as the card's component name. */
|
|
5
|
+
export declare const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
|
|
6
|
+
/** argv (sans leading "docker") that launches a product container: detached,
|
|
7
|
+
* auto-removed, loopback-published to its internal 4321, and role-labelled so
|
|
8
|
+
* it shows up in the Infrastructure tab. */
|
|
9
|
+
export declare function productRunArgs(image: string, hostPort: number): string[];
|
|
10
|
+
/** Stable container name for a product, derived from its manifest name, so
|
|
11
|
+
* `docker ps` shows `norsk-product-studio` instead of a random docker alias.
|
|
12
|
+
* Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
|
|
13
|
+
export declare function productContainerName(productName: string): string;
|
|
14
|
+
export declare function dockerRun(image: string, hostPort: number): Promise<string>;
|
|
15
|
+
export declare function dockerRm(containerId: string): Promise<void>;
|
|
16
|
+
/** Best-effort rename of a running container. The product is tracked by
|
|
17
|
+
* container id, never by name, so a rename failure (e.g. a stale container
|
|
18
|
+
* already holding the name) is non-fatal — the product still works, it just
|
|
19
|
+
* keeps its random docker alias. */
|
|
20
|
+
export declare function dockerRename(containerId: string, name: string): Promise<void>;
|
package/docker-runner.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ProductError } from "./product-error.js";
|
|
2
|
+
const CONTAINER_INTERNAL_PORT = 4321;
|
|
3
|
+
/** Role label stamped on every product control-plane container. The daemon's
|
|
4
|
+
* infrastructure view discovers managed singletons by the presence of a
|
|
5
|
+
* `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
|
|
6
|
+
* value doubles as the card's component name. */
|
|
7
|
+
export const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
|
|
8
|
+
/** argv (sans leading "docker") that launches a product container: detached,
|
|
9
|
+
* auto-removed, loopback-published to its internal 4321, and role-labelled so
|
|
10
|
+
* it shows up in the Infrastructure tab. */
|
|
11
|
+
export function productRunArgs(image, hostPort) {
|
|
12
|
+
return [
|
|
13
|
+
"run",
|
|
14
|
+
"-d",
|
|
15
|
+
"--rm",
|
|
16
|
+
"--label",
|
|
17
|
+
PRODUCT_ROLE_LABEL,
|
|
18
|
+
"-p",
|
|
19
|
+
`127.0.0.1:${hostPort}:${CONTAINER_INTERNAL_PORT}`,
|
|
20
|
+
image,
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
/** Stable container name for a product, derived from its manifest name, so
|
|
24
|
+
* `docker ps` shows `norsk-product-studio` instead of a random docker alias.
|
|
25
|
+
* Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
|
|
26
|
+
export function productContainerName(productName) {
|
|
27
|
+
const slug = productName
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
return `norsk-product-${slug || "unnamed"}`;
|
|
32
|
+
}
|
|
33
|
+
export async function dockerRun(image, hostPort) {
|
|
34
|
+
const proc = Bun.spawn(["docker", ...productRunArgs(image, hostPort)], { stdout: "pipe", stderr: "pipe" });
|
|
35
|
+
const exitCode = await proc.exited;
|
|
36
|
+
const stdout = (await new Response(proc.stdout).text()).trim();
|
|
37
|
+
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
38
|
+
if (exitCode !== 0 || !stdout) {
|
|
39
|
+
throw new ProductError("DOCKER_RUN_FAILED", `docker run failed (exit ${exitCode}): ${stderr || "no stderr"}`);
|
|
40
|
+
}
|
|
41
|
+
return stdout;
|
|
42
|
+
}
|
|
43
|
+
export async function dockerRm(containerId) {
|
|
44
|
+
const proc = Bun.spawn(["docker", "rm", "-f", containerId], { stdout: "pipe", stderr: "pipe" });
|
|
45
|
+
await proc.exited;
|
|
46
|
+
}
|
|
47
|
+
/** Best-effort rename of a running container. The product is tracked by
|
|
48
|
+
* container id, never by name, so a rename failure (e.g. a stale container
|
|
49
|
+
* already holding the name) is non-fatal — the product still works, it just
|
|
50
|
+
* keeps its random docker alias. */
|
|
51
|
+
export async function dockerRename(containerId, name) {
|
|
52
|
+
const proc = Bun.spawn(["docker", "rename", containerId, name], { stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
await proc.exited;
|
|
54
|
+
}
|