@locode2/harness 0.1.0-preview.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/README.md +27 -0
- package/THIRD_PARTY_LICENSES.txt +32769 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/apps/host/dist/document-parser-worker.js +215 -0
- package/apps/host/dist/frontend-runtime-worker.js +1755 -0
- package/apps/host/dist/index.js +23380 -0
- package/apps/web/dist/assets/LocalMonacoEditor-CXkKwxiP.css +1 -0
- package/apps/web/dist/assets/LocalMonacoEditor-WKAHQmzG.js +940 -0
- package/apps/web/dist/assets/LogsView-BFFFSFB-.js +1 -0
- package/apps/web/dist/assets/PluginsView-DM_ZnENS.js +1 -0
- package/apps/web/dist/assets/SettingsView-Cb5Q4ile.js +1 -0
- package/apps/web/dist/assets/abap-DLDM7-KI.js +1 -0
- package/apps/web/dist/assets/apex-DNDY2TF8.js +1 -0
- package/apps/web/dist/assets/azcli-Y6nb8tq_.js +1 -0
- package/apps/web/dist/assets/bat-BwHxbl9M.js +1 -0
- package/apps/web/dist/assets/bicep-CFznDFnq.js +2 -0
- package/apps/web/dist/assets/cameligo-Bf6VGUru.js +1 -0
- package/apps/web/dist/assets/clojure-Dnu-v4kV.js +1 -0
- package/apps/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/apps/web/dist/assets/coffee-Bd8akH9Z.js +1 -0
- package/apps/web/dist/assets/cpp-BbWJElDN.js +1 -0
- package/apps/web/dist/assets/csharp-Co3qMtFm.js +1 -0
- package/apps/web/dist/assets/csp-D-4FJmMZ.js +1 -0
- package/apps/web/dist/assets/css-DdJfP1eB.js +3 -0
- package/apps/web/dist/assets/css.worker-11mhRRLC.js +97 -0
- package/apps/web/dist/assets/cssMode-L5f5LAEn.js +1 -0
- package/apps/web/dist/assets/cypher-cTPe9QuQ.js +1 -0
- package/apps/web/dist/assets/dart-BOtBlQCF.js +1 -0
- package/apps/web/dist/assets/dockerfile-BG73LgW2.js +1 -0
- package/apps/web/dist/assets/ecl-BEgZUVRK.js +1 -0
- package/apps/web/dist/assets/editor.worker-DysRFSaV.js +30 -0
- package/apps/web/dist/assets/elixir-BkW5O-1t.js +1 -0
- package/apps/web/dist/assets/flow9-BeJ5waoc.js +1 -0
- package/apps/web/dist/assets/freemarker2-UOwftS75.js +3 -0
- package/apps/web/dist/assets/fsharp-PahG7c26.js +1 -0
- package/apps/web/dist/assets/go-acbASCJo.js +1 -0
- package/apps/web/dist/assets/graphql-BxJiqAUM.js +1 -0
- package/apps/web/dist/assets/handlebars-C_E41ffr.js +1 -0
- package/apps/web/dist/assets/hcl-DtV1sZF8.js +1 -0
- package/apps/web/dist/assets/html-HsrixH2x.js +1 -0
- package/apps/web/dist/assets/html.worker-C8Tw8ncf.js +474 -0
- package/apps/web/dist/assets/htmlMode-DipGmEOi.js +1 -0
- package/apps/web/dist/assets/index-COUF63cT.js +213 -0
- package/apps/web/dist/assets/index-SNSmF2-c.css +1 -0
- package/apps/web/dist/assets/ini-Kd9XrMLS.js +1 -0
- package/apps/web/dist/assets/java-CXBNlu9o.js +1 -0
- package/apps/web/dist/assets/javascript-D36WEqF9.js +1 -0
- package/apps/web/dist/assets/json.worker-BzkYZgJ7.js +62 -0
- package/apps/web/dist/assets/jsonMode-D9BrIats.js +7 -0
- package/apps/web/dist/assets/julia-cl7-CwDS.js +1 -0
- package/apps/web/dist/assets/kotlin-s7OhZKlX.js +1 -0
- package/apps/web/dist/assets/less-9HpZscsL.js +2 -0
- package/apps/web/dist/assets/lexon-OrD6JF1K.js +1 -0
- package/apps/web/dist/assets/liquid-DES89Tjc.js +1 -0
- package/apps/web/dist/assets/lock-keyhole-jo-FJcGq.js +1 -0
- package/apps/web/dist/assets/lspLanguageFeatures-YKkgcn4q.js +4 -0
- package/apps/web/dist/assets/lua-Cyyb5UIc.js +1 -0
- package/apps/web/dist/assets/m3-B8OfTtLu.js +1 -0
- package/apps/web/dist/assets/markdown-BFxVWTOG.js +1 -0
- package/apps/web/dist/assets/mdx-D1Bfax8e.js +1 -0
- package/apps/web/dist/assets/mips-CiqrrVzr.js +1 -0
- package/apps/web/dist/assets/msdax-DmeGPVcC.js +1 -0
- package/apps/web/dist/assets/mysql-C_tMU-Nz.js +1 -0
- package/apps/web/dist/assets/objective-c-BDtDVThU.js +1 -0
- package/apps/web/dist/assets/pascal-vHIfCaH5.js +1 -0
- package/apps/web/dist/assets/pascaligo-DtZ0uQbO.js +1 -0
- package/apps/web/dist/assets/perl-Ub6l9XKa.js +1 -0
- package/apps/web/dist/assets/pgsql-BlNEE0v7.js +1 -0
- package/apps/web/dist/assets/php-BBUBE1dy.js +1 -0
- package/apps/web/dist/assets/pla-DSh2-awV.js +1 -0
- package/apps/web/dist/assets/postiats-CocnycG-.js +1 -0
- package/apps/web/dist/assets/powerquery-tScXyioY.js +1 -0
- package/apps/web/dist/assets/powershell-COWaemsV.js +1 -0
- package/apps/web/dist/assets/protobuf-Brw8urJB.js +2 -0
- package/apps/web/dist/assets/pug-8SOpv6rk.js +1 -0
- package/apps/web/dist/assets/python-DKhPhY0N.js +1 -0
- package/apps/web/dist/assets/qsharp-Bw9ernYp.js +1 -0
- package/apps/web/dist/assets/r-j7ic8hl3.js +1 -0
- package/apps/web/dist/assets/razor-Cha8qrI6.js +1 -0
- package/apps/web/dist/assets/redis-Bu5POkcn.js +1 -0
- package/apps/web/dist/assets/redshift-Bs9aos_-.js +1 -0
- package/apps/web/dist/assets/restructuredtext-CqXO7rUv.js +1 -0
- package/apps/web/dist/assets/ruby-zBfavPgS.js +1 -0
- package/apps/web/dist/assets/rust-BzKRNQWT.js +1 -0
- package/apps/web/dist/assets/sb-BBc9UKZt.js +1 -0
- package/apps/web/dist/assets/scala-D9hQfWCl.js +1 -0
- package/apps/web/dist/assets/scheme-BPhDTwHR.js +1 -0
- package/apps/web/dist/assets/scss-CBJaRo0y.js +3 -0
- package/apps/web/dist/assets/shell-DiJ1NA_G.js +1 -0
- package/apps/web/dist/assets/solidity-Db0IVjzk.js +1 -0
- package/apps/web/dist/assets/sophia-CnS9iZB_.js +1 -0
- package/apps/web/dist/assets/sparql-CJmd_6j2.js +1 -0
- package/apps/web/dist/assets/sql-ClhHkBeG.js +1 -0
- package/apps/web/dist/assets/st-CHwy0fLd.js +1 -0
- package/apps/web/dist/assets/swift-CnmFD0ga.js +1 -0
- package/apps/web/dist/assets/systemverilog-Bs9z6M-B.js +1 -0
- package/apps/web/dist/assets/tcl-Dm6ycUr_.js +1 -0
- package/apps/web/dist/assets/ts.worker-Bupg6llt.js +67722 -0
- package/apps/web/dist/assets/tsMode-CEhssXv8.js +11 -0
- package/apps/web/dist/assets/twig-Csy3S7wG.js +1 -0
- package/apps/web/dist/assets/typescript-DWqmw58R.js +1 -0
- package/apps/web/dist/assets/typespec-Btyra-wh.js +1 -0
- package/apps/web/dist/assets/vb-Db0cS2oM.js +1 -0
- package/apps/web/dist/assets/wgsl-DumH7NcR.js +298 -0
- package/apps/web/dist/assets/workers-BmZvNFRq.js +1 -0
- package/apps/web/dist/assets/xml-WQTAFV9b.js +1 -0
- package/apps/web/dist/assets/yaml-Ke2Lf79d.js +1 -0
- package/apps/web/dist/index.html +15 -0
- package/bin/launch.mjs +71 -0
- package/bin/locode-mini.mjs +12 -0
- package/bin/options.mjs +49 -0
- package/bin/restore-assets.mjs +40 -0
- package/bundled-plugins/fast-frontend/.gitignore +2 -0
- package/bundled-plugins/fast-frontend/README.md +29 -0
- package/bundled-plugins/fast-frontend/harness.plugin.json +123 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/PUBLIC_CONTRACT.md +139 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/contracts/app.schema.json +24 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/contracts/route.schema.json +52 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/examples/app.json +9 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/examples/route.json +8 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/index.html +17 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/package.json +31 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/scripts/smoke-build.mjs +40 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/src/inspector/index.ts +612 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/src/main.tsx +17 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/src/sdk/index.ts +1028 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/src/shell/RuntimeApp.tsx +380 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/src/shell/runtime.css +246 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/tsconfig.json +20 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/types/runtime.consumer-smoke.ts +70 -0
- package/bundled-plugins/fast-frontend/runtimes/antd-admin/types/runtime.d.ts +227 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/.gitignore +9 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/.harness/template.json +10 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/index.html +13 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/package.json +34 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/pnpm-lock.yaml +1451 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/pnpm-workspace.yaml +4 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/public/generated/.gitkeep +1 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/scripts/validate-routes.mjs +153 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/app/App.tsx +27 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/app/NotFoundPage.tsx +15 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/features/.gitkeep +1 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/layouts/MainLayout.tsx +77 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/main.tsx +14 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/mocks/dashboard.ts +48 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/pages/dashboard/page.tsx +151 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/pages/dashboard/route.ts +9 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/routes/definition.ts +21 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/routes/menu.tsx +44 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/routes/registry.ts +48 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/routes/router.tsx +34 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/shared/components/business/.gitkeep +1 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/shared/components/business/BusinessProviders.tsx +9 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/styles/global.css +180 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/src/vite-env.d.ts +1 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/tsconfig.app.json +24 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/tsconfig.json +7 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/tsconfig.node.json +17 -0
- package/bundled-plugins/fast-frontend/templates/antd-admin/vite.config.ts +39 -0
- package/npm-shrinkwrap.json +9737 -0
- package/package.json +80 -0
- package/release-integrity.json +168 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
useSyncExternalStore,
|
|
11
|
+
type ComponentType,
|
|
12
|
+
type CSSProperties,
|
|
13
|
+
type Dispatch,
|
|
14
|
+
type Key,
|
|
15
|
+
type LazyExoticComponent,
|
|
16
|
+
type PropsWithChildren,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
type SetStateAction,
|
|
19
|
+
} from "react";
|
|
20
|
+
import {
|
|
21
|
+
Button,
|
|
22
|
+
Card,
|
|
23
|
+
Col,
|
|
24
|
+
Drawer,
|
|
25
|
+
Form,
|
|
26
|
+
Row,
|
|
27
|
+
Space,
|
|
28
|
+
Statistic,
|
|
29
|
+
Table,
|
|
30
|
+
Tag,
|
|
31
|
+
Typography,
|
|
32
|
+
type CardProps,
|
|
33
|
+
type DrawerProps,
|
|
34
|
+
type FormInstance,
|
|
35
|
+
type FormProps,
|
|
36
|
+
type RowProps,
|
|
37
|
+
type StatisticProps,
|
|
38
|
+
type TableProps,
|
|
39
|
+
type TagProps,
|
|
40
|
+
} from "antd";
|
|
41
|
+
|
|
42
|
+
export interface FrontendAppTheme {
|
|
43
|
+
readonly colorPrimary?: string;
|
|
44
|
+
readonly borderRadius?: number;
|
|
45
|
+
readonly compact?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Business-owned values loaded from `src/app.json`. */
|
|
49
|
+
export interface FrontendBusinessAppManifest {
|
|
50
|
+
readonly name?: string;
|
|
51
|
+
/** Project-relative URL below `./generated/`; remote URLs are forbidden. */
|
|
52
|
+
readonly logo?: string;
|
|
53
|
+
readonly theme?: FrontendAppTheme;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Business-owned values loaded from each page's sibling `route.json`. */
|
|
57
|
+
export interface FrontendRouteMetadata {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly title: string;
|
|
61
|
+
readonly icon?: string;
|
|
62
|
+
readonly order?: number;
|
|
63
|
+
readonly parentId?: string;
|
|
64
|
+
readonly hidden?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FrontendPageComponent = ComponentType<Record<string, never>>;
|
|
68
|
+
export type FrontendLazyPageComponent = LazyExoticComponent<FrontendPageComponent>;
|
|
69
|
+
export type FrontendRuntimeProvider = ComponentType<PropsWithChildren>;
|
|
70
|
+
|
|
71
|
+
export interface FrontendBusinessRoute {
|
|
72
|
+
readonly metadata: FrontendRouteMetadata;
|
|
73
|
+
readonly Component: FrontendLazyPageComponent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FrontendRuntimeApi {
|
|
77
|
+
readonly app: FrontendBusinessAppManifest;
|
|
78
|
+
readonly routes: readonly FrontendRouteMetadata[];
|
|
79
|
+
readonly currentPath: string;
|
|
80
|
+
navigate(to: string): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Synchronous, browser-compatible storage exposed by the Runtime.
|
|
85
|
+
*
|
|
86
|
+
* The preview iframe intentionally has an opaque origin, so accessing the
|
|
87
|
+
* browser's native Storage object may throw. In Harness it transparently uses
|
|
88
|
+
* a narrowly scoped parent-page bridge; outside Harness it safely falls back
|
|
89
|
+
* to memory when native storage is unavailable.
|
|
90
|
+
*/
|
|
91
|
+
export interface FrontendStorage {
|
|
92
|
+
readonly length: number;
|
|
93
|
+
clear(): void;
|
|
94
|
+
getItem(key: string): string | null;
|
|
95
|
+
key(index: number): string | null;
|
|
96
|
+
removeItem(key: string): void;
|
|
97
|
+
setItem(key: string, value: string): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const STORAGE_PREFIX = "__harness_frontend_runtime__";
|
|
101
|
+
const STORAGE_BRIDGE_VERSION = 1 as const;
|
|
102
|
+
const STORAGE_BRIDGE_READY = "harness.runtime-storage.ready";
|
|
103
|
+
const STORAGE_BRIDGE_INITIALIZE = "harness.runtime-storage.initialize";
|
|
104
|
+
const STORAGE_BRIDGE_MUTATION = "harness.runtime-storage.mutation";
|
|
105
|
+
const STORAGE_BRIDGE_MAX_NAMESPACE_BYTES = 128;
|
|
106
|
+
const STORAGE_BRIDGE_MAX_KEY_BYTES = 512;
|
|
107
|
+
const STORAGE_BRIDGE_MAX_VALUE_BYTES = 256 * 1024;
|
|
108
|
+
const STORAGE_BRIDGE_MAX_ENTRIES = 512;
|
|
109
|
+
const STORAGE_BRIDGE_MAX_TOTAL_BYTES = 1024 * 1024;
|
|
110
|
+
|
|
111
|
+
type FrontendStorageMutation =
|
|
112
|
+
| { readonly operation: "set"; readonly key: string; readonly value: string }
|
|
113
|
+
| { readonly operation: "remove"; readonly key: string }
|
|
114
|
+
| { readonly operation: "clear" };
|
|
115
|
+
|
|
116
|
+
interface BridgeStorageAdapter {
|
|
117
|
+
hydrateFromBridge(values: ReadonlyMap<string, string>): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bridgeByteLength(value: string): number {
|
|
121
|
+
return new TextEncoder().encode(value).byteLength;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isBridgeRecord(value: unknown): value is Record<string, unknown> {
|
|
125
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseBridgeNamespaces(value: unknown): ReadonlyMap<string, ReadonlyMap<string, string>> | undefined {
|
|
129
|
+
if (!isBridgeRecord(value)) return undefined;
|
|
130
|
+
const namespaces = new Map<string, ReadonlyMap<string, string>>();
|
|
131
|
+
let entryCount = 0;
|
|
132
|
+
let totalBytes = 0;
|
|
133
|
+
for (const [namespace, rawValues] of Object.entries(value)) {
|
|
134
|
+
const namespaceBytes = bridgeByteLength(namespace);
|
|
135
|
+
if (namespaceBytes === 0 || namespaceBytes > STORAGE_BRIDGE_MAX_NAMESPACE_BYTES || !isBridgeRecord(rawValues)) return undefined;
|
|
136
|
+
const values = new Map<string, string>();
|
|
137
|
+
for (const [key, rawValue] of Object.entries(rawValues)) {
|
|
138
|
+
const keyBytes = bridgeByteLength(key);
|
|
139
|
+
if (typeof rawValue !== "string" || keyBytes === 0 || keyBytes > STORAGE_BRIDGE_MAX_KEY_BYTES) return undefined;
|
|
140
|
+
const valueBytes = bridgeByteLength(rawValue);
|
|
141
|
+
if (valueBytes > STORAGE_BRIDGE_MAX_VALUE_BYTES) return undefined;
|
|
142
|
+
entryCount += 1;
|
|
143
|
+
totalBytes += namespaceBytes + keyBytes + valueBytes;
|
|
144
|
+
if (entryCount > STORAGE_BRIDGE_MAX_ENTRIES || totalBytes > STORAGE_BRIDGE_MAX_TOTAL_BYTES) return undefined;
|
|
145
|
+
values.set(key, rawValue);
|
|
146
|
+
}
|
|
147
|
+
namespaces.set(namespace, values);
|
|
148
|
+
}
|
|
149
|
+
return namespaces;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class RuntimeStorageBridgeClient {
|
|
153
|
+
readonly #parentWindow: Window | undefined;
|
|
154
|
+
readonly #adapters = new Map<string, Set<BridgeStorageAdapter>>();
|
|
155
|
+
#channelId: string | undefined;
|
|
156
|
+
#namespaces: ReadonlyMap<string, ReadonlyMap<string, string>> | undefined;
|
|
157
|
+
#requestSequence = 0;
|
|
158
|
+
|
|
159
|
+
constructor() {
|
|
160
|
+
if (typeof window === "undefined" || window.parent === window) {
|
|
161
|
+
this.#parentWindow = undefined;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.#parentWindow = window.parent;
|
|
165
|
+
window.addEventListener("message", this.#handleMessage);
|
|
166
|
+
this.#post({ type: STORAGE_BRIDGE_READY, version: STORAGE_BRIDGE_VERSION });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get available(): boolean {
|
|
170
|
+
return this.#parentWindow !== undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
register(namespace: string, adapter: BridgeStorageAdapter): void {
|
|
174
|
+
const adapters = this.#adapters.get(namespace) ?? new Set<BridgeStorageAdapter>();
|
|
175
|
+
adapters.add(adapter);
|
|
176
|
+
this.#adapters.set(namespace, adapters);
|
|
177
|
+
const values = this.#namespaces?.get(namespace);
|
|
178
|
+
if (this.#channelId !== undefined && this.#namespaces !== undefined) {
|
|
179
|
+
adapter.hydrateFromBridge(values ?? new Map());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
mutate(namespace: string, mutation: FrontendStorageMutation): void {
|
|
184
|
+
if (this.#channelId === undefined) return;
|
|
185
|
+
this.#requestSequence += 1;
|
|
186
|
+
this.#post({
|
|
187
|
+
type: STORAGE_BRIDGE_MUTATION,
|
|
188
|
+
version: STORAGE_BRIDGE_VERSION,
|
|
189
|
+
channelId: this.#channelId,
|
|
190
|
+
requestId: `${Date.now().toString(36)}-${this.#requestSequence.toString(36)}`,
|
|
191
|
+
namespace,
|
|
192
|
+
...mutation,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
readonly #handleMessage = (event: MessageEvent<unknown>): void => {
|
|
197
|
+
if (event.source !== this.#parentWindow || !isBridgeRecord(event.data)) return;
|
|
198
|
+
if (event.data.type !== STORAGE_BRIDGE_INITIALIZE || event.data.version !== STORAGE_BRIDGE_VERSION) return;
|
|
199
|
+
const channelId = event.data.channelId;
|
|
200
|
+
if (typeof channelId !== "string" || bridgeByteLength(channelId) === 0 || bridgeByteLength(channelId) > 128) return;
|
|
201
|
+
if (this.#channelId !== undefined) return;
|
|
202
|
+
const namespaces = parseBridgeNamespaces(event.data.namespaces);
|
|
203
|
+
if (namespaces === undefined) return;
|
|
204
|
+
this.#channelId = channelId;
|
|
205
|
+
this.#namespaces = namespaces;
|
|
206
|
+
for (const [namespace, adapters] of this.#adapters) {
|
|
207
|
+
const values = namespaces.get(namespace) ?? new Map<string, string>();
|
|
208
|
+
for (const adapter of adapters) adapter.hydrateFromBridge(values);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
#post(message: Record<string, unknown>): void {
|
|
213
|
+
try {
|
|
214
|
+
// The child has an opaque origin and cannot derive a stable parent
|
|
215
|
+
// targetOrigin. The parent validates this exact WindowProxy and a random
|
|
216
|
+
// channel before accepting any mutation.
|
|
217
|
+
this.#parentWindow?.postMessage(message, "*");
|
|
218
|
+
} catch {
|
|
219
|
+
// Standalone exports and disappearing parent windows remain usable.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const runtimeStorageBridge = new RuntimeStorageBridgeClient();
|
|
225
|
+
const storageAdapters = new Map<string, SafeFrontendStorage>();
|
|
226
|
+
|
|
227
|
+
class SafeFrontendStorage implements FrontendStorage, BridgeStorageAdapter {
|
|
228
|
+
readonly #memory = new Map<string, string>();
|
|
229
|
+
readonly #prefix: string;
|
|
230
|
+
readonly #namespace: string;
|
|
231
|
+
readonly #readyListeners = new Set<() => void>();
|
|
232
|
+
readonly #pendingMutations: FrontendStorageMutation[] = [];
|
|
233
|
+
#persistent: Storage | undefined;
|
|
234
|
+
#ready = true;
|
|
235
|
+
|
|
236
|
+
constructor(namespace: string) {
|
|
237
|
+
this.#namespace = namespace;
|
|
238
|
+
this.#prefix = `${STORAGE_PREFIX}:${encodeURIComponent(namespace)}:`;
|
|
239
|
+
try {
|
|
240
|
+
// Accessing this property itself throws in a sandboxed opaque-origin
|
|
241
|
+
// iframe. Keep it inside the same safety boundary as all Storage calls.
|
|
242
|
+
this.#persistent = globalThis.localStorage;
|
|
243
|
+
if (this.#persistent !== undefined) this.#hydrateFromPersistentStorage();
|
|
244
|
+
else this.#enableBridge();
|
|
245
|
+
} catch {
|
|
246
|
+
this.#persistent = undefined;
|
|
247
|
+
this.#enableBridge();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
get ready(): boolean {
|
|
252
|
+
return this.#ready;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onReady(listener: () => void): () => void {
|
|
256
|
+
if (this.#ready) {
|
|
257
|
+
listener();
|
|
258
|
+
return () => undefined;
|
|
259
|
+
}
|
|
260
|
+
this.#readyListeners.add(listener);
|
|
261
|
+
return () => { this.#readyListeners.delete(listener); };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
hydrateFromBridge(values: ReadonlyMap<string, string>): void {
|
|
265
|
+
this.#memory.clear();
|
|
266
|
+
for (const [key, value] of values) this.#memory.set(key, value);
|
|
267
|
+
this.#ready = true;
|
|
268
|
+
const pending = this.#pendingMutations.splice(0);
|
|
269
|
+
for (const mutation of pending) {
|
|
270
|
+
this.#applyMemoryMutation(mutation);
|
|
271
|
+
runtimeStorageBridge.mutate(this.#namespace, mutation);
|
|
272
|
+
}
|
|
273
|
+
for (const listener of [...this.#readyListeners]) listener();
|
|
274
|
+
this.#readyListeners.clear();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get length(): number {
|
|
278
|
+
return this.#memory.size;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
clear(): void {
|
|
282
|
+
const keys = [...this.#memory.keys()];
|
|
283
|
+
this.#memory.clear();
|
|
284
|
+
const persistent = this.#persistent;
|
|
285
|
+
if (persistent === undefined) {
|
|
286
|
+
this.#bridgeMutation({ operation: "clear" });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
for (const key of keys) persistent.removeItem(this.#storageKey(key));
|
|
291
|
+
} catch {
|
|
292
|
+
this.#persistent = undefined;
|
|
293
|
+
this.#enableBridge({ operation: "clear" });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
getItem(key: string): string | null {
|
|
298
|
+
const normalizedKey = String(key);
|
|
299
|
+
const persistent = this.#persistent;
|
|
300
|
+
if (persistent !== undefined) {
|
|
301
|
+
try {
|
|
302
|
+
const value = persistent.getItem(this.#storageKey(normalizedKey));
|
|
303
|
+
if (value === null) this.#memory.delete(normalizedKey);
|
|
304
|
+
else this.#memory.set(normalizedKey, value);
|
|
305
|
+
return value;
|
|
306
|
+
} catch {
|
|
307
|
+
this.#persistent = undefined;
|
|
308
|
+
this.#enableBridge();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return this.#memory.get(normalizedKey) ?? null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
key(index: number): string | null {
|
|
315
|
+
if (!Number.isInteger(index) || index < 0) return null;
|
|
316
|
+
return [...this.#memory.keys()][index] ?? null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
removeItem(key: string): void {
|
|
320
|
+
const normalizedKey = String(key);
|
|
321
|
+
this.#memory.delete(normalizedKey);
|
|
322
|
+
const persistent = this.#persistent;
|
|
323
|
+
const mutation = { operation: "remove", key: normalizedKey } as const;
|
|
324
|
+
if (persistent === undefined) {
|
|
325
|
+
this.#bridgeMutation(mutation);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
persistent.removeItem(this.#storageKey(normalizedKey));
|
|
330
|
+
} catch {
|
|
331
|
+
this.#persistent = undefined;
|
|
332
|
+
this.#enableBridge(mutation);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setItem(key: string, value: string): void {
|
|
337
|
+
const normalizedKey = String(key);
|
|
338
|
+
const normalizedValue = String(value);
|
|
339
|
+
// Mirror first so a quota/security failure cannot lose the current write.
|
|
340
|
+
this.#memory.set(normalizedKey, normalizedValue);
|
|
341
|
+
const persistent = this.#persistent;
|
|
342
|
+
const mutation = { operation: "set", key: normalizedKey, value: normalizedValue } as const;
|
|
343
|
+
if (persistent === undefined) {
|
|
344
|
+
this.#bridgeMutation(mutation);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
persistent.setItem(this.#storageKey(normalizedKey), normalizedValue);
|
|
349
|
+
} catch {
|
|
350
|
+
this.#persistent = undefined;
|
|
351
|
+
this.#enableBridge(mutation);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#hydrateFromPersistentStorage(): void {
|
|
356
|
+
const persistent = this.#persistent;
|
|
357
|
+
if (persistent === undefined) return;
|
|
358
|
+
for (let index = 0; index < persistent.length; index += 1) {
|
|
359
|
+
const storedKey = persistent.key(index);
|
|
360
|
+
if (storedKey === null || !storedKey.startsWith(this.#prefix)) continue;
|
|
361
|
+
const value = persistent.getItem(storedKey);
|
|
362
|
+
if (value !== null) this.#memory.set(storedKey.slice(this.#prefix.length), value);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#storageKey(key: string): string {
|
|
367
|
+
return `${this.#prefix}${key}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#enableBridge(pending?: FrontendStorageMutation): void {
|
|
371
|
+
if (!runtimeStorageBridge.available) return;
|
|
372
|
+
if (pending !== undefined) this.#pendingMutations.push(pending);
|
|
373
|
+
if (!this.#ready) return;
|
|
374
|
+
this.#ready = false;
|
|
375
|
+
runtimeStorageBridge.register(this.#namespace, this);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#bridgeMutation(mutation: FrontendStorageMutation): void {
|
|
379
|
+
if (!runtimeStorageBridge.available) return;
|
|
380
|
+
if (!this.#ready) {
|
|
381
|
+
this.#pendingMutations.push(mutation);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
runtimeStorageBridge.mutate(this.#namespace, mutation);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#applyMemoryMutation(mutation: FrontendStorageMutation): void {
|
|
388
|
+
if (mutation.operation === "clear") {
|
|
389
|
+
this.#memory.clear();
|
|
390
|
+
} else if (mutation.operation === "remove") {
|
|
391
|
+
this.#memory.delete(mutation.key);
|
|
392
|
+
} else {
|
|
393
|
+
this.#memory.set(mutation.key, mutation.value);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Returns a synchronous application storage adapter isolated by namespace.
|
|
400
|
+
* Native localStorage is used when available. Harness previews use the scoped
|
|
401
|
+
* parent bridge after an access failure; standalone restricted environments
|
|
402
|
+
* retain an in-memory fallback instead of crashing React.
|
|
403
|
+
*/
|
|
404
|
+
function getFrontendStorageAdapter(namespace = "default"): SafeFrontendStorage {
|
|
405
|
+
const normalizedNamespace = namespace.trim() || "default";
|
|
406
|
+
const existing = storageAdapters.get(normalizedNamespace);
|
|
407
|
+
if (existing !== undefined) return existing;
|
|
408
|
+
const adapter = new SafeFrontendStorage(normalizedNamespace);
|
|
409
|
+
storageAdapters.set(normalizedNamespace, adapter);
|
|
410
|
+
return adapter;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function getFrontendStorage(namespace = "default"): FrontendStorage {
|
|
414
|
+
return getFrontendStorageAdapter(namespace);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export type FrontendPageWidth = "standard" | "wide" | "full";
|
|
418
|
+
|
|
419
|
+
export interface FrontendShellUser {
|
|
420
|
+
readonly name: ReactNode;
|
|
421
|
+
readonly description?: ReactNode;
|
|
422
|
+
readonly avatar?: ReactNode;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface FrontendShellOptions {
|
|
426
|
+
/** Global actions rendered in the Runtime-owned top header. */
|
|
427
|
+
readonly headerActions?: ReactNode;
|
|
428
|
+
/** Current business user rendered by the Runtime instead of a page-owned header. */
|
|
429
|
+
readonly currentUser?: FrontendShellUser;
|
|
430
|
+
/** A controlled user/role switcher, usually an Ant Design Select. */
|
|
431
|
+
readonly userSwitcher?: ReactNode;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface FrontendPageOptions {
|
|
435
|
+
/** Runtime-owned page heading. Omit it to reuse the active route title. */
|
|
436
|
+
readonly title?: ReactNode;
|
|
437
|
+
readonly description?: ReactNode;
|
|
438
|
+
/** Actions aligned with the Runtime-owned page title. */
|
|
439
|
+
readonly titleActions?: ReactNode;
|
|
440
|
+
readonly width?: FrontendPageWidth;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** @internal Shared with the immutable Runtime shell. */
|
|
444
|
+
export interface FrontendChromeRegistrationApi {
|
|
445
|
+
readonly routeKey: string;
|
|
446
|
+
registerShell(options: FrontendShellOptions): () => void;
|
|
447
|
+
registerPage(routeKey: string, options: FrontendPageOptions): () => void;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const FrontendChromeContext = createContext<FrontendChromeRegistrationApi | null>(null);
|
|
451
|
+
|
|
452
|
+
interface FrontendLiveNodeSlot {
|
|
453
|
+
getSnapshot(): ReactNode;
|
|
454
|
+
publish(value: ReactNode): void;
|
|
455
|
+
subscribe(listener: () => void): () => void;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function createFrontendLiveNodeSlot(initialValue: ReactNode): FrontendLiveNodeSlot {
|
|
459
|
+
let value = initialValue;
|
|
460
|
+
const listeners = new Set<() => void>();
|
|
461
|
+
return {
|
|
462
|
+
getSnapshot: () => value,
|
|
463
|
+
publish: (nextValue) => {
|
|
464
|
+
if (Object.is(value, nextValue)) return;
|
|
465
|
+
value = nextValue;
|
|
466
|
+
for (const listener of [...listeners]) listener();
|
|
467
|
+
},
|
|
468
|
+
subscribe: (listener) => {
|
|
469
|
+
listeners.add(listener);
|
|
470
|
+
return () => { listeners.delete(listener); };
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function FrontendLiveNode({ slot }: { readonly slot: FrontendLiveNodeSlot }): ReactNode {
|
|
476
|
+
return useSyncExternalStore(slot.subscribe, slot.getSnapshot, slot.getSnapshot);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The Runtime is browser-only, but the SDK contract is also rendered in Node
|
|
480
|
+
// smoke tests. Avoid the useLayoutEffect server warning without delaying slot
|
|
481
|
+
// updates in the real preview.
|
|
482
|
+
const useFrontendLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Keeps a chrome slot registered once while independently refreshing its
|
|
486
|
+
* ReactNode. This is important for inline callbacks: using their identity as an
|
|
487
|
+
* effect dependency would create a registration/render loop, while ignoring
|
|
488
|
+
* them would leave the header or page action holding a stale closure.
|
|
489
|
+
*/
|
|
490
|
+
function useFrontendLiveNode(value: ReactNode): ReactNode {
|
|
491
|
+
const slot = useRef<FrontendLiveNodeSlot | null>(null);
|
|
492
|
+
if (slot.current === null) slot.current = createFrontendLiveNodeSlot(value);
|
|
493
|
+
const liveSlot = slot.current;
|
|
494
|
+
const node = useRef<ReactNode | undefined>(undefined);
|
|
495
|
+
if (node.current === undefined) {
|
|
496
|
+
node.current = createElement(FrontendLiveNode, { slot: liveSlot });
|
|
497
|
+
}
|
|
498
|
+
useFrontendLayoutEffect(() => {
|
|
499
|
+
liveSlot.publish(value);
|
|
500
|
+
}, [liveSlot, value]);
|
|
501
|
+
return node.current;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** @internal Runtime-owned provider exported through the singleton SDK module. */
|
|
505
|
+
export function FrontendChromeContextProvider({
|
|
506
|
+
value,
|
|
507
|
+
children,
|
|
508
|
+
}: PropsWithChildren<{ readonly value: FrontendChromeRegistrationApi }>): ReactNode {
|
|
509
|
+
return createElement(FrontendChromeContext.Provider, { value }, children);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Register controlled application-wide content in the Runtime-owned header. */
|
|
513
|
+
export function useFrontendShell(options: FrontendShellOptions): void {
|
|
514
|
+
const chrome = useContext(FrontendChromeContext);
|
|
515
|
+
const headerActions = useFrontendLiveNode(options.headerActions);
|
|
516
|
+
const userSwitcher = useFrontendLiveNode(options.userSwitcher);
|
|
517
|
+
const currentUserName = useFrontendLiveNode(options.currentUser?.name);
|
|
518
|
+
const currentUserDescription = useFrontendLiveNode(options.currentUser?.description);
|
|
519
|
+
const currentUserAvatar = useFrontendLiveNode(options.currentUser?.avatar);
|
|
520
|
+
const hasHeaderActions = options.headerActions !== undefined;
|
|
521
|
+
const hasUserSwitcher = options.userSwitcher !== undefined;
|
|
522
|
+
const hasCurrentUser = options.currentUser !== undefined;
|
|
523
|
+
const hasCurrentUserDescription = options.currentUser?.description !== undefined;
|
|
524
|
+
const hasCurrentUserAvatar = options.currentUser?.avatar !== undefined;
|
|
525
|
+
|
|
526
|
+
useEffect(() => chrome?.registerShell({
|
|
527
|
+
...(hasHeaderActions ? { headerActions } : {}),
|
|
528
|
+
...(hasUserSwitcher ? { userSwitcher } : {}),
|
|
529
|
+
...(hasCurrentUser ? {
|
|
530
|
+
currentUser: {
|
|
531
|
+
name: currentUserName,
|
|
532
|
+
...(hasCurrentUserDescription ? { description: currentUserDescription } : {}),
|
|
533
|
+
...(hasCurrentUserAvatar ? { avatar: currentUserAvatar } : {}),
|
|
534
|
+
},
|
|
535
|
+
} : {}),
|
|
536
|
+
}), [
|
|
537
|
+
chrome,
|
|
538
|
+
currentUserAvatar,
|
|
539
|
+
currentUserDescription,
|
|
540
|
+
currentUserName,
|
|
541
|
+
hasCurrentUser,
|
|
542
|
+
hasCurrentUserAvatar,
|
|
543
|
+
hasCurrentUserDescription,
|
|
544
|
+
hasHeaderActions,
|
|
545
|
+
hasUserSwitcher,
|
|
546
|
+
headerActions,
|
|
547
|
+
userSwitcher,
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Register the current page title, title actions and content width with the Runtime shell. */
|
|
552
|
+
export function useFrontendPage(options: FrontendPageOptions): void {
|
|
553
|
+
const chrome = useContext(FrontendChromeContext);
|
|
554
|
+
const title = useFrontendLiveNode(options.title);
|
|
555
|
+
const description = useFrontendLiveNode(options.description);
|
|
556
|
+
const titleActions = useFrontendLiveNode(options.titleActions);
|
|
557
|
+
const hasTitle = options.title !== undefined;
|
|
558
|
+
const hasDescription = options.description !== undefined;
|
|
559
|
+
const hasTitleActions = options.titleActions !== undefined;
|
|
560
|
+
useEffect(
|
|
561
|
+
() => chrome?.registerPage(chrome.routeKey, {
|
|
562
|
+
...(hasTitle ? { title } : {}),
|
|
563
|
+
...(hasDescription ? { description } : {}),
|
|
564
|
+
...(hasTitleActions ? { titleActions } : {}),
|
|
565
|
+
...(options.width === undefined ? {} : { width: options.width }),
|
|
566
|
+
}),
|
|
567
|
+
[
|
|
568
|
+
chrome,
|
|
569
|
+
description,
|
|
570
|
+
hasDescription,
|
|
571
|
+
hasTitle,
|
|
572
|
+
hasTitleActions,
|
|
573
|
+
options.width,
|
|
574
|
+
title,
|
|
575
|
+
titleActions,
|
|
576
|
+
],
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export type FrontendShellProviderProps = PropsWithChildren<FrontendShellOptions>;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Place this in `src/app/providers.tsx` to fill Runtime header slots without
|
|
584
|
+
* creating a second application Header inside business pages.
|
|
585
|
+
*/
|
|
586
|
+
export function FrontendShellProvider({
|
|
587
|
+
children,
|
|
588
|
+
headerActions,
|
|
589
|
+
currentUser,
|
|
590
|
+
userSwitcher,
|
|
591
|
+
}: FrontendShellProviderProps): ReactNode {
|
|
592
|
+
useFrontendShell({ headerActions, currentUser, userSwitcher });
|
|
593
|
+
return children;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface PageContainerProps extends PropsWithChildren<FrontendPageOptions> {
|
|
597
|
+
readonly className?: string;
|
|
598
|
+
readonly style?: CSSProperties;
|
|
599
|
+
readonly "data-harness-source"?: string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Standard business page root whose title and width are owned by the shell. */
|
|
603
|
+
export function PageContainer({
|
|
604
|
+
children,
|
|
605
|
+
className,
|
|
606
|
+
style,
|
|
607
|
+
title,
|
|
608
|
+
description,
|
|
609
|
+
titleActions,
|
|
610
|
+
width = "wide",
|
|
611
|
+
"data-harness-source": sourceMarker,
|
|
612
|
+
}: PageContainerProps): ReactNode {
|
|
613
|
+
useFrontendPage({ title, description, titleActions, width });
|
|
614
|
+
return createElement("div", {
|
|
615
|
+
className: ["harness-page-container", className].filter(Boolean).join(" "),
|
|
616
|
+
style,
|
|
617
|
+
...(sourceMarker ? { "data-harness-source": sourceMarker } : {}),
|
|
618
|
+
}, children);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export interface FrontendMetric {
|
|
622
|
+
readonly key: Key;
|
|
623
|
+
readonly title: ReactNode;
|
|
624
|
+
readonly value: StatisticProps["value"];
|
|
625
|
+
readonly prefix?: ReactNode;
|
|
626
|
+
readonly suffix?: ReactNode;
|
|
627
|
+
readonly precision?: number;
|
|
628
|
+
readonly valueStyle?: CSSProperties;
|
|
629
|
+
readonly description?: ReactNode;
|
|
630
|
+
readonly loading?: boolean;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export interface MetricGridProps {
|
|
634
|
+
readonly items: readonly FrontendMetric[];
|
|
635
|
+
readonly columns?: 1 | 2 | 3 | 4 | 6;
|
|
636
|
+
readonly gutter?: RowProps["gutter"];
|
|
637
|
+
readonly className?: string;
|
|
638
|
+
readonly style?: CSSProperties;
|
|
639
|
+
readonly "data-harness-source"?: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Responsive Ant Design metric cards with consistent spacing and height. */
|
|
643
|
+
export function MetricGrid({
|
|
644
|
+
items,
|
|
645
|
+
columns = 4,
|
|
646
|
+
gutter = [16, 16],
|
|
647
|
+
className,
|
|
648
|
+
style,
|
|
649
|
+
"data-harness-source": sourceMarker,
|
|
650
|
+
}: MetricGridProps): ReactNode {
|
|
651
|
+
const largeSpan = 24 / columns;
|
|
652
|
+
return createElement(Row, {
|
|
653
|
+
gutter,
|
|
654
|
+
className: ["harness-runtime-metric-grid", className].filter(Boolean).join(" "),
|
|
655
|
+
style,
|
|
656
|
+
...(sourceMarker ? { "data-harness-source": sourceMarker } : {}),
|
|
657
|
+
}, items.map((item) => createElement(
|
|
658
|
+
Col,
|
|
659
|
+
{
|
|
660
|
+
key: item.key,
|
|
661
|
+
xs: 24,
|
|
662
|
+
sm: columns === 1 ? 24 : 12,
|
|
663
|
+
lg: largeSpan,
|
|
664
|
+
},
|
|
665
|
+
createElement(
|
|
666
|
+
Card,
|
|
667
|
+
{
|
|
668
|
+
loading: item.loading,
|
|
669
|
+
className: "harness-runtime-metric-card",
|
|
670
|
+
},
|
|
671
|
+
createElement(Statistic, {
|
|
672
|
+
title: item.title,
|
|
673
|
+
value: item.value,
|
|
674
|
+
prefix: item.prefix,
|
|
675
|
+
suffix: item.suffix,
|
|
676
|
+
precision: item.precision,
|
|
677
|
+
valueStyle: item.valueStyle,
|
|
678
|
+
}),
|
|
679
|
+
item.description === undefined
|
|
680
|
+
? null
|
|
681
|
+
: createElement(
|
|
682
|
+
Typography.Text,
|
|
683
|
+
{ type: "secondary", className: "harness-runtime-metric-description" },
|
|
684
|
+
item.description,
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
)));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export interface DataTableProps<RecordType extends object = Record<string, unknown>>
|
|
691
|
+
extends Omit<TableProps<RecordType>, "title"> {
|
|
692
|
+
readonly title?: ReactNode;
|
|
693
|
+
readonly description?: ReactNode;
|
|
694
|
+
readonly toolbar?: ReactNode;
|
|
695
|
+
readonly cardProps?: Omit<CardProps, "children" | "extra" | "title">;
|
|
696
|
+
readonly "data-harness-source"?: string;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Ant Design Table in a consistent responsive toolbar/card shell. */
|
|
700
|
+
export function DataTable<RecordType extends object = Record<string, unknown>>({
|
|
701
|
+
title,
|
|
702
|
+
description,
|
|
703
|
+
toolbar,
|
|
704
|
+
cardProps,
|
|
705
|
+
pagination,
|
|
706
|
+
scroll,
|
|
707
|
+
size = "middle",
|
|
708
|
+
"data-harness-source": sourceMarker,
|
|
709
|
+
...tableProps
|
|
710
|
+
}: DataTableProps<RecordType>): ReactNode {
|
|
711
|
+
const heading = title === undefined && description === undefined
|
|
712
|
+
? undefined
|
|
713
|
+
: createElement("div", { className: "harness-runtime-data-table-heading" },
|
|
714
|
+
title === undefined
|
|
715
|
+
? null
|
|
716
|
+
: createElement(Typography.Title, { level: 5, ellipsis: true }, title),
|
|
717
|
+
description === undefined
|
|
718
|
+
? null
|
|
719
|
+
: createElement(Typography.Text, { type: "secondary" }, description));
|
|
720
|
+
const TypedTable = Table<RecordType>;
|
|
721
|
+
return createElement(Card, {
|
|
722
|
+
...cardProps,
|
|
723
|
+
className: ["harness-runtime-data-table-card", cardProps?.className].filter(Boolean).join(" "),
|
|
724
|
+
title: heading,
|
|
725
|
+
extra: toolbar,
|
|
726
|
+
...(sourceMarker ? { "data-harness-source": sourceMarker } : {}),
|
|
727
|
+
}, createElement(TypedTable, {
|
|
728
|
+
...tableProps,
|
|
729
|
+
size,
|
|
730
|
+
scroll: scroll ?? { x: "max-content" },
|
|
731
|
+
pagination: pagination === undefined
|
|
732
|
+
? { showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }
|
|
733
|
+
: pagination,
|
|
734
|
+
}));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export interface CrudDrawerProps<Values extends object = Record<string, unknown>>
|
|
738
|
+
extends Omit<DrawerProps, "children" | "footer" | "onClose" | "open"> {
|
|
739
|
+
readonly open: boolean;
|
|
740
|
+
readonly form?: FormInstance<Values>;
|
|
741
|
+
readonly formProps?: Omit<FormProps<Values>, "children" | "form" | "initialValues" | "onFinish">;
|
|
742
|
+
readonly initialValues?: Partial<Values>;
|
|
743
|
+
readonly children?: ReactNode;
|
|
744
|
+
readonly submitText?: ReactNode;
|
|
745
|
+
readonly cancelText?: ReactNode;
|
|
746
|
+
readonly submitting?: boolean;
|
|
747
|
+
readonly onClose: () => void;
|
|
748
|
+
readonly onSubmit: (values: Values) => void | Promise<void>;
|
|
749
|
+
readonly onSubmitError?: (error: unknown) => void;
|
|
750
|
+
readonly "data-harness-source"?: string;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Controlled CRUD form Drawer with validation, footer and submit state. */
|
|
754
|
+
export function CrudDrawer<Values extends object = Record<string, unknown>>({
|
|
755
|
+
open,
|
|
756
|
+
form: providedForm,
|
|
757
|
+
formProps,
|
|
758
|
+
initialValues,
|
|
759
|
+
children,
|
|
760
|
+
submitText = "保存",
|
|
761
|
+
cancelText = "取消",
|
|
762
|
+
submitting,
|
|
763
|
+
onClose,
|
|
764
|
+
onSubmit,
|
|
765
|
+
onSubmitError,
|
|
766
|
+
width = 520,
|
|
767
|
+
"data-harness-source": sourceMarker,
|
|
768
|
+
...drawerProps
|
|
769
|
+
}: CrudDrawerProps<Values>): ReactNode {
|
|
770
|
+
const [internalForm] = Form.useForm<Values>();
|
|
771
|
+
const TypedForm = Form<Values>;
|
|
772
|
+
const form = providedForm ?? internalForm;
|
|
773
|
+
const [internalSubmitting, setInternalSubmitting] = useState(false);
|
|
774
|
+
const isSubmitting = submitting ?? internalSubmitting;
|
|
775
|
+
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
if (!open) return;
|
|
778
|
+
form.resetFields();
|
|
779
|
+
if (initialValues !== undefined) form.setFieldsValue(initialValues as Values);
|
|
780
|
+
}, [form, initialValues, open]);
|
|
781
|
+
|
|
782
|
+
const submit = async () => {
|
|
783
|
+
if (isSubmitting) return;
|
|
784
|
+
setInternalSubmitting(true);
|
|
785
|
+
try {
|
|
786
|
+
const values = await form.validateFields();
|
|
787
|
+
await onSubmit(values);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
onSubmitError?.(error);
|
|
790
|
+
} finally {
|
|
791
|
+
setInternalSubmitting(false);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
return createElement(Drawer, {
|
|
796
|
+
...drawerProps,
|
|
797
|
+
open,
|
|
798
|
+
width,
|
|
799
|
+
onClose,
|
|
800
|
+
destroyOnHidden: true,
|
|
801
|
+
...(sourceMarker ? { "data-harness-source": sourceMarker } : {}),
|
|
802
|
+
footer: createElement("div", { className: "harness-runtime-crud-drawer-footer" },
|
|
803
|
+
createElement(Space, null,
|
|
804
|
+
createElement(Button, { onClick: onClose, disabled: isSubmitting }, cancelText),
|
|
805
|
+
createElement(Button, {
|
|
806
|
+
type: "primary",
|
|
807
|
+
loading: isSubmitting,
|
|
808
|
+
onClick: () => { void submit(); },
|
|
809
|
+
}, submitText))),
|
|
810
|
+
}, createElement(TypedForm, {
|
|
811
|
+
...formProps,
|
|
812
|
+
form,
|
|
813
|
+
layout: formProps?.layout ?? "vertical",
|
|
814
|
+
requiredMark: formProps?.requiredMark ?? "optional",
|
|
815
|
+
}, children));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const DEFAULT_STATUS_COLORS: Readonly<Record<string, TagProps["color"]>> = {
|
|
819
|
+
"未开始": "default",
|
|
820
|
+
"待处理": "warning",
|
|
821
|
+
"待办": "warning",
|
|
822
|
+
"进行中": "processing",
|
|
823
|
+
"处理中": "processing",
|
|
824
|
+
"已完成": "success",
|
|
825
|
+
"成功": "success",
|
|
826
|
+
"已关闭": "default",
|
|
827
|
+
"已取消": "default",
|
|
828
|
+
"失败": "error",
|
|
829
|
+
"已逾期": "error",
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
export interface StatusTagProps extends Omit<TagProps, "children" | "color"> {
|
|
833
|
+
readonly status: string;
|
|
834
|
+
readonly label?: ReactNode;
|
|
835
|
+
readonly color?: TagProps["color"];
|
|
836
|
+
readonly colors?: Readonly<Record<string, TagProps["color"]>>;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Consistent status colors with an explicit per-application override map. */
|
|
840
|
+
export function StatusTag({
|
|
841
|
+
status,
|
|
842
|
+
label,
|
|
843
|
+
color,
|
|
844
|
+
colors,
|
|
845
|
+
...tagProps
|
|
846
|
+
}: StatusTagProps): ReactNode {
|
|
847
|
+
return createElement(Tag, {
|
|
848
|
+
...tagProps,
|
|
849
|
+
color: color ?? colors?.[status] ?? DEFAULT_STATUS_COLORS[status] ?? "default",
|
|
850
|
+
}, label ?? status);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export type FrontendStoreSetter<TState> = TState | ((current: TState) => TState);
|
|
854
|
+
export type FrontendStorePatch<TState extends object> = Partial<TState> | ((current: TState) => Partial<TState>);
|
|
855
|
+
|
|
856
|
+
export interface FrontendStore<TState extends object> {
|
|
857
|
+
getState(): TState;
|
|
858
|
+
setState(next: FrontendStoreSetter<TState>): void;
|
|
859
|
+
update(patch: FrontendStorePatch<TState>): void;
|
|
860
|
+
reset(): void;
|
|
861
|
+
subscribe(listener: () => void): () => void;
|
|
862
|
+
useStore<Selection = TState>(selector?: (state: TState) => Selection): Selection;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Small dependency-free external store suitable for shared mock business data. */
|
|
866
|
+
export function createFrontendStore<TState extends object>(
|
|
867
|
+
initialState: TState | (() => TState),
|
|
868
|
+
): FrontendStore<TState> {
|
|
869
|
+
const createInitialState = () => typeof initialState === "function"
|
|
870
|
+
? (initialState as () => TState)()
|
|
871
|
+
: initialState;
|
|
872
|
+
let state = createInitialState();
|
|
873
|
+
const listeners = new Set<() => void>();
|
|
874
|
+
const getState = () => state;
|
|
875
|
+
const subscribe = (listener: () => void) => {
|
|
876
|
+
listeners.add(listener);
|
|
877
|
+
return () => listeners.delete(listener);
|
|
878
|
+
};
|
|
879
|
+
const setState = (next: FrontendStoreSetter<TState>) => {
|
|
880
|
+
const value = typeof next === "function"
|
|
881
|
+
? (next as (current: TState) => TState)(state)
|
|
882
|
+
: next;
|
|
883
|
+
if (Object.is(value, state)) return;
|
|
884
|
+
state = value;
|
|
885
|
+
for (const listener of [...listeners]) listener();
|
|
886
|
+
};
|
|
887
|
+
const store: FrontendStore<TState> = {
|
|
888
|
+
getState,
|
|
889
|
+
setState,
|
|
890
|
+
update: (patch) => setState((current) => ({
|
|
891
|
+
...current,
|
|
892
|
+
...(typeof patch === "function" ? patch(current) : patch),
|
|
893
|
+
})),
|
|
894
|
+
reset: () => setState(createInitialState()),
|
|
895
|
+
subscribe,
|
|
896
|
+
useStore: <Selection = TState>(selector?: (value: TState) => Selection): Selection => {
|
|
897
|
+
const snapshot = useSyncExternalStore(subscribe, getState, getState);
|
|
898
|
+
return selector === undefined ? snapshot as unknown as Selection : selector(snapshot);
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
return store;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export interface PersistentFrontendStateOptions<T> {
|
|
905
|
+
readonly namespace?: string;
|
|
906
|
+
readonly serialize?: (value: T) => string;
|
|
907
|
+
readonly deserialize?: (value: string) => T;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** useState-compatible state persisted through the Runtime's safe Storage adapter. */
|
|
911
|
+
export function usePersistentFrontendState<T>(
|
|
912
|
+
key: string,
|
|
913
|
+
initialState: T | (() => T),
|
|
914
|
+
options: PersistentFrontendStateOptions<T> = {},
|
|
915
|
+
): [T, Dispatch<SetStateAction<T>>] {
|
|
916
|
+
const configuration = useRef<{
|
|
917
|
+
readonly storage: SafeFrontendStorage;
|
|
918
|
+
readonly key: string;
|
|
919
|
+
readonly serialize: (value: T) => string;
|
|
920
|
+
readonly deserialize: (value: string) => T;
|
|
921
|
+
} | null>(null);
|
|
922
|
+
let config = configuration.current;
|
|
923
|
+
if (config === null) {
|
|
924
|
+
config = {
|
|
925
|
+
storage: getFrontendStorageAdapter(options.namespace),
|
|
926
|
+
key,
|
|
927
|
+
serialize: options.serialize ?? ((value) => JSON.stringify(value) ?? "null"),
|
|
928
|
+
deserialize: options.deserialize ?? ((value) => JSON.parse(value) as T),
|
|
929
|
+
};
|
|
930
|
+
configuration.current = config;
|
|
931
|
+
}
|
|
932
|
+
const hydrated = useRef(config.storage.ready);
|
|
933
|
+
const changedBeforeHydration = useRef(false);
|
|
934
|
+
const skipPersistenceOnce = useRef(false);
|
|
935
|
+
const [state, setState] = useState<T>(() => {
|
|
936
|
+
const stored = config.storage.getItem(config.key);
|
|
937
|
+
if (stored !== null) {
|
|
938
|
+
try {
|
|
939
|
+
return config.deserialize(stored);
|
|
940
|
+
} catch {
|
|
941
|
+
// Invalid or outdated data falls back to the caller's safe default.
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return typeof initialState === "function" ? (initialState as () => T)() : initialState;
|
|
945
|
+
});
|
|
946
|
+
const stateRef = useRef(state);
|
|
947
|
+
stateRef.current = state;
|
|
948
|
+
const setPersistentState = useCallback<Dispatch<SetStateAction<T>>>((next) => {
|
|
949
|
+
if (!hydrated.current) changedBeforeHydration.current = true;
|
|
950
|
+
const value = typeof next === "function"
|
|
951
|
+
? (next as (current: T) => T)(stateRef.current)
|
|
952
|
+
: next;
|
|
953
|
+
stateRef.current = value;
|
|
954
|
+
setState(value);
|
|
955
|
+
}, []);
|
|
956
|
+
|
|
957
|
+
useEffect(() => {
|
|
958
|
+
if (hydrated.current) return;
|
|
959
|
+
return config.storage.onReady(() => {
|
|
960
|
+
if (hydrated.current) return;
|
|
961
|
+
hydrated.current = true;
|
|
962
|
+
if (changedBeforeHydration.current) {
|
|
963
|
+
try {
|
|
964
|
+
config.storage.setItem(config.key, config.serialize(stateRef.current));
|
|
965
|
+
} catch {
|
|
966
|
+
// A custom serializer must not crash bridge hydration.
|
|
967
|
+
}
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const stored = config.storage.getItem(config.key);
|
|
971
|
+
if (stored !== null) {
|
|
972
|
+
try {
|
|
973
|
+
skipPersistenceOnce.current = true;
|
|
974
|
+
setState(config.deserialize(stored));
|
|
975
|
+
return;
|
|
976
|
+
} catch {
|
|
977
|
+
skipPersistenceOnce.current = false;
|
|
978
|
+
// Invalid or outdated data is replaced with the caller's safe default.
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
config.storage.setItem(config.key, config.serialize(stateRef.current));
|
|
983
|
+
} catch {
|
|
984
|
+
// A custom serializer must not crash bridge hydration.
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
}, [config]);
|
|
988
|
+
|
|
989
|
+
useEffect(() => {
|
|
990
|
+
if (!hydrated.current) return;
|
|
991
|
+
if (skipPersistenceOnce.current) {
|
|
992
|
+
skipPersistenceOnce.current = false;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
try {
|
|
996
|
+
config.storage.setItem(config.key, config.serialize(state));
|
|
997
|
+
} catch {
|
|
998
|
+
// A custom serializer must not crash the page render lifecycle.
|
|
999
|
+
}
|
|
1000
|
+
}, [config, state]);
|
|
1001
|
+
return [state, setPersistentState];
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const FrontendRuntimeContext = createContext<FrontendRuntimeApi | null>(null);
|
|
1005
|
+
|
|
1006
|
+
/** Runtime-internal provider exported through the same singleton SDK module. */
|
|
1007
|
+
export function FrontendRuntimeContextProvider({
|
|
1008
|
+
value,
|
|
1009
|
+
children,
|
|
1010
|
+
}: PropsWithChildren<{ readonly value: FrontendRuntimeApi }>): ReactNode {
|
|
1011
|
+
return createElement(FrontendRuntimeContext.Provider, { value }, children);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** Access app metadata and navigate without depending on Runtime internals. */
|
|
1015
|
+
export function useFrontendRuntime(): FrontendRuntimeApi {
|
|
1016
|
+
const value = useContext(FrontendRuntimeContext);
|
|
1017
|
+
if (value === null) {
|
|
1018
|
+
throw new Error("useFrontendRuntime must be used inside the Harness frontend runtime");
|
|
1019
|
+
}
|
|
1020
|
+
return value;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Type-safe identity helper for business provider modules. */
|
|
1024
|
+
export function defineFrontendProviders(
|
|
1025
|
+
providers: readonly FrontendRuntimeProvider[],
|
|
1026
|
+
): readonly FrontendRuntimeProvider[] {
|
|
1027
|
+
return providers;
|
|
1028
|
+
}
|