@77-zhike/app-sdk 0.2.0-d7b1.10

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.
Files changed (87) hide show
  1. package/README.md +251 -0
  2. package/package.json +71 -0
  3. package/src/bin.js +15 -0
  4. package/src/cli/api-client.js +249 -0
  5. package/src/cli/browser-auth.js +212 -0
  6. package/src/cli/credential-store.js +101 -0
  7. package/src/cli/development-app.js +77 -0
  8. package/src/cli/development-logs.js +183 -0
  9. package/src/cli/development-watch.js +205 -0
  10. package/src/cli/index.js +256 -0
  11. package/src/cli/local-commands.js +80 -0
  12. package/src/cli/logs.js +56 -0
  13. package/src/cli/project-init.js +259 -0
  14. package/src/cli/upload-bundle.js +73 -0
  15. package/src/client/analysis.d.ts +4 -0
  16. package/src/client/analysis.js +12 -0
  17. package/src/client/destinations.d.ts +25 -0
  18. package/src/client/destinations.js +70 -0
  19. package/src/client/external-url.js +9 -0
  20. package/src/client/index.d.ts +249 -0
  21. package/src/client/index.js +72 -0
  22. package/src/client/interactions.d.ts +23 -0
  23. package/src/client/interactions.js +62 -0
  24. package/src/client/points.generated.js +67 -0
  25. package/src/config.js +68 -0
  26. package/src/development-workspace.js +154 -0
  27. package/src/errors.js +20 -0
  28. package/src/forms/array-contract.d.ts +32 -0
  29. package/src/forms/array-contract.js +138 -0
  30. package/src/forms/components.generated.js +4 -0
  31. package/src/forms/factory.js +41 -0
  32. package/src/forms/field-array.d.ts +16 -0
  33. package/src/forms/field-array.js +52 -0
  34. package/src/forms/index.d.ts +118 -0
  35. package/src/forms/index.js +5 -0
  36. package/src/forms/normalize-options.js +19 -0
  37. package/src/forms/options.d.ts +30 -0
  38. package/src/forms/options.js +55 -0
  39. package/src/forms/record-options.d.ts +28 -0
  40. package/src/forms/record-options.js +92 -0
  41. package/src/forms/schema.d.ts +82 -0
  42. package/src/forms/schema.js +128 -0
  43. package/src/forms/state.d.ts +30 -0
  44. package/src/forms/state.js +82 -0
  45. package/src/forms/validation.d.ts +9 -0
  46. package/src/forms/validation.js +81 -0
  47. package/src/forms/values.d.ts +5 -0
  48. package/src/forms/values.js +38 -0
  49. package/src/index.d.ts +13 -0
  50. package/src/index.js +2 -0
  51. package/src/jsx-dev-runtime.d.ts +2 -0
  52. package/src/jsx-dev-runtime.js +1 -0
  53. package/src/jsx-runtime.d.ts +2 -0
  54. package/src/jsx-runtime.js +1 -0
  55. package/src/project-binding.js +84 -0
  56. package/src/server/index.d.ts +318 -0
  57. package/src/server/index.js +242 -0
  58. package/src/server/steps/index.d.ts +71 -0
  59. package/src/server/steps/index.js +15 -0
  60. package/src/server/triggers/index.d.ts +68 -0
  61. package/src/server/triggers/index.js +27 -0
  62. package/src/server/workflow-blocks/index.d.ts +72 -0
  63. package/src/server/workflow-blocks/index.js +32 -0
  64. package/src/toolchain/allowed-packages.json +7 -0
  65. package/src/toolchain/canonical-json.js +17 -0
  66. package/src/toolchain/dependency-policy.js +181 -0
  67. package/src/toolchain/index.js +15 -0
  68. package/src/toolchain/real-build.js +459 -0
  69. package/src/toolchain/source-discovery.js +919 -0
  70. package/src/toolchain/transport-tar.js +168 -0
  71. package/src/toolchain/webhook-response.js +50 -0
  72. package/src/toolchain/workflow-blocks/discovery.js +133 -0
  73. package/src/toolchain/workflow-steps/manifest.js +99 -0
  74. package/src/toolchain/workflow-triggers/manifest.js +47 -0
  75. package/src/ui/components.generated.js +5 -0
  76. package/src/ui/events.d.ts +10 -0
  77. package/src/ui/factory.js +70 -0
  78. package/src/ui/icons.generated.js +2 -0
  79. package/src/ui/index.d.ts +343 -0
  80. package/src/ui/index.js +4 -0
  81. package/src/ui/jsx.generated.d.ts +359 -0
  82. package/src/ui/native-dom.d.ts +49 -0
  83. package/src/ui/native-dom.js +44 -0
  84. package/src/ui/style.d.ts +5 -0
  85. package/src/ui/style.generated.js +2 -0
  86. package/src/ui/style.js +15 -0
  87. package/src/ui/validation.js +86 -0
@@ -0,0 +1,55 @@
1
+ import { sdkIconNames } from '../ui/icons.generated.js';
2
+ import { badgeColors } from '../ui/components.generated.js';
3
+ import { assertExternalUrl } from '../client/external-url.js';
4
+ // SDK 回调与 Host 共用有界 DTO 验证;函数留在 Guest,不传递 Provider 对象。
5
+ const fail = () => { throw new Error('runtime_options_invalid'); };
6
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ const only = (value, keys) => { if (!object(value) || Object.keys(value).some(key => !keys.includes(key))) fail(); };
8
+ export const OPTIONS_PAGE_LIMIT = 100;
9
+ export function assertOption(option) {
10
+ only(option, ['value', 'label', 'description', 'disabled', 'disabledReason', 'categoryLabel', 'avatarUrl', 'icon', 'color']);
11
+ assertOptionValue(option.value);
12
+ if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
13
+ for (const key of ['description', 'disabledReason']) {
14
+ if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
15
+ }
16
+ if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
17
+ if (option.categoryLabel !== undefined && (typeof option.categoryLabel !== 'string' || !option.categoryLabel.trim() || option.categoryLabel.length > 200)) fail();
18
+ if (option.avatarUrl !== undefined) { try { assertExternalUrl(option.avatarUrl); } catch { fail(); } }
19
+ if (option.icon !== undefined && !sdkIconNames.includes(option.icon)) fail();
20
+ if (option.color !== undefined && !badgeColors.includes(option.color)) fail();
21
+ return option;
22
+ }
23
+ export function assertOptionValue(value) {
24
+ if (typeof value !== 'string' || !value || value.length > 512) fail();
25
+ return value;
26
+ }
27
+ export function assertOptions(options) {
28
+ if (!Array.isArray(options) || options.length > OPTIONS_PAGE_LIMIT) fail();
29
+ const values = new Set();
30
+ for (const option of options) {
31
+ assertOption(option);
32
+ if (values.has(option.value)) fail();
33
+ values.add(option.value);
34
+ }
35
+ return options;
36
+ }
37
+ export function assertOptionsSearch(input) {
38
+ only(input, ['query', 'cursor']);
39
+ if (typeof input.query !== 'string' || input.query.length > 512) fail();
40
+ if (input.cursor !== undefined && (typeof input.cursor !== 'string' || !input.cursor || input.cursor.length > 4096)) fail();
41
+ return input;
42
+ }
43
+ export function assertOptionsPage(page) {
44
+ only(page, ['items', 'nextCursor']);
45
+ assertOptions(page.items);
46
+ if (page.nextCursor !== null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 4096)) fail();
47
+ return page;
48
+ }
49
+ export function assertSelectedOption(option, value) {
50
+ assertOptionValue(value);
51
+ if (option === null) return null;
52
+ assertOption(option);
53
+ if (option.value !== value) fail();
54
+ return option;
55
+ }
@@ -0,0 +1,28 @@
1
+ export type RecordRef = {
2
+ /** 数据源范围内稳定的公开对象键,不是内部模型名或授权证明。 */
3
+ objectSlug: string;
4
+ recordId: string;
5
+ };
6
+ export type RecordObjectOption = { objectSlug: string; label: string; }
7
+ export type RecordOption = {
8
+ value: RecordRef;
9
+ label: string;
10
+ description?: string;
11
+ disabled?: boolean;
12
+ disabledReason?: string;
13
+ }
14
+ export type RecordOptionsPage = { items: RecordOption[]; nextCursor: string | null; }
15
+ export interface RecordOptionsProvider {
16
+ search(input: { objectSlug: string; query: string; cursor?: string }): Promise<RecordOptionsPage>;
17
+ /** null 表示未解析;查询错误必须拒绝 Promise,不能静默清空引用。 */
18
+ getOption(value: RecordRef): Promise<RecordOption | null>;
19
+ }
20
+ export function recordKey(value: RecordRef): string;
21
+ export function assertRecordRef(value: unknown, objects?: readonly RecordObjectOption[]): RecordRef;
22
+ export function assertRecordObjects(value: unknown): RecordObjectOption[];
23
+ export function assertRecordSearch(value: unknown, objects?: readonly RecordObjectOption[]): { objectSlug: string; query: string; cursor?: string };
24
+ export function assertRecordOption(value: unknown): RecordOption;
25
+ export function assertRecordPage(value: unknown, objectSlug: string): RecordOptionsPage;
26
+ export function assertSelectedRecordOption(value: unknown, ref: RecordRef): RecordOption | null;
27
+
28
+ export function recordObjectsForField(field: { objectSlug?: string; objects?: readonly RecordObjectOption[]; label: string }): readonly RecordObjectOption[];
@@ -0,0 +1,92 @@
1
+ import { assertOptionValue, assertOptionsSearch } from './options.js';
2
+ const fail = () => { throw new Error('runtime_record_options_invalid'); };
3
+ const only = (value, keys) => {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
5
+ };
6
+ /** 仅用于 Host 选择器 identity,不作为公开保存值或传输编码。 */
7
+ export const recordKey = value => JSON.stringify([value.objectSlug, value.recordId]);
8
+ export function assertRecordRef(value, objects) {
9
+ only(value, ['objectSlug', 'recordId']);
10
+ assertOptionValue(value.objectSlug); assertOptionValue(value.recordId);
11
+ if (objects && !objects.some(object => object.objectSlug === value.objectSlug)) fail();
12
+ return value;
13
+ }
14
+ export function assertRecordObjects(objects) {
15
+ if (!Array.isArray(objects) || objects.length !== 1) fail();
16
+ const seen = new Set();
17
+ for (const object of objects) {
18
+ only(object, ['objectSlug', 'label']);
19
+ assertOptionValue(object.objectSlug);
20
+ if (seen.has(object.objectSlug) || typeof object.label !== 'string' || !object.label.trim() || object.label.length > 200) fail();
21
+ seen.add(object.objectSlug);
22
+ }
23
+ return objects;
24
+ }
25
+ /** 新配置绑定唯一对象;旧数组仅允许恰好一项。 */
26
+ export function recordObjectsForField(field) {
27
+ if (field.objectSlug !== undefined) {
28
+ if (field.objects !== undefined) fail();
29
+ assertOptionValue(field.objectSlug);
30
+ return [{ objectSlug: field.objectSlug, label: field.label }];
31
+ }
32
+ return assertRecordObjects(field.objects);
33
+ }
34
+ export function assertRecordSearch(input, objects) {
35
+ only(input, ['objectSlug', 'query', 'cursor']);
36
+ assertOptionValue(input.objectSlug);
37
+ if (objects && !objects.some(object => object.objectSlug === input.objectSlug)) fail();
38
+ assertOptionsSearch({ query: input.query, ...(input.cursor === undefined ? {} : { cursor: input.cursor }) });
39
+ return input;
40
+ }
41
+ export function assertRecordOption(option) {
42
+ only(option, ['value', 'label', 'description', 'disabled', 'disabledReason']);
43
+ assertRecordRef(option.value);
44
+ if (typeof option.label !== 'string' || !option.label || option.label.length > 200) fail();
45
+ for (const key of ['description', 'disabledReason']) if (option[key] !== undefined && (typeof option[key] !== 'string' || option[key].length > 4096)) fail();
46
+ if (option.disabled !== undefined && typeof option.disabled !== 'boolean') fail();
47
+ return option;
48
+ }
49
+ export function assertRecordPage(page, objectSlug) {
50
+ only(page, ['items', 'nextCursor']);
51
+ if (!Array.isArray(page.items) || page.items.length > 100) fail();
52
+ if (page.nextCursor !== null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 4096)) fail();
53
+ const seen = new Set();
54
+ for (const item of page.items) {
55
+ assertRecordOption(item);
56
+ const key = recordKey(item.value);
57
+ if (item.value.objectSlug !== objectSlug || seen.has(key)) fail();
58
+ seen.add(key);
59
+ }
60
+ return page;
61
+ }
62
+ export function assertSelectedRecordOption(option, ref) {
63
+ assertRecordRef(ref);
64
+ if (option === null) return null;
65
+ assertRecordOption(option);
66
+ if (recordKey(option.value) !== recordKey(ref)) fail();
67
+ return option;
68
+ }
69
+ export function useNormalizedRecordOptions({ useRef, useMemo }, options, objects) {
70
+ assertRecordObjects(objects);
71
+ const source = useRef({ options: null, search: null, getOption: null, revision: 0 });
72
+ const search = options?.search, getOption = options?.getOption;
73
+ if (source.current.options !== options || source.current.search !== search || source.current.getOption !== getOption) {
74
+ source.current = { options, search, getOption, revision: source.current.revision + 1 };
75
+ }
76
+ const revision = source.current.revision;
77
+ return useMemo(() => {
78
+ if (options === undefined) return {};
79
+ if (!options || typeof search !== 'function' || typeof getOption !== 'function') fail();
80
+ return {
81
+ sourceRevision: String(revision),
82
+ onSearch: input => {
83
+ assertRecordSearch(input, objects);
84
+ return Promise.resolve(search.call(options, input)).then(page => assertRecordPage(page, input.objectSlug));
85
+ },
86
+ onGetOption: value => {
87
+ assertRecordRef(value, objects);
88
+ return Promise.resolve(getOption.call(options, value)).then(option => assertSelectedRecordOption(option, value));
89
+ },
90
+ };
91
+ }, [revision]);
92
+ }
@@ -0,0 +1,82 @@
1
+ import type { RecordRef, RecordObjectOption } from "./record-options.js";
2
+ export interface RuleMessages { required?: string; min?: string; max?: string; format?: string; }
3
+ export type FormField = (
4
+ | { type: "string"; label: string; required?: boolean; minLength?: number; maxLength?: number; format?: "email" | "url" }
5
+ | { type: "boolean"; label: string; required?: boolean }
6
+ | { type: "enum"; label: string; required?: boolean; options: readonly { value: string; label: string; description?: string }[] }
7
+ | { type: "number"; label: string; required?: boolean; min?: number; max?: number }
8
+ | { type: "plainDate" | "dateTime"; label: string; required?: boolean; min?: string; max?: string }
9
+ | ({ type: "record"; label: string; required?: boolean } & ({ objectSlug: string; objects?: never } | { objectSlug?: never; objects: readonly [RecordObjectOption] }))) & { messages?: RuleMessages };
10
+ export type LeafDefinition = FormField | { type: FormField['type']; label: (text: string) => unknown };
11
+ export interface ArrayField<F extends Record<string, LeafDefinition> = Record<string, LeafDefinition>> {
12
+ type: 'array'; label: string; minItems: number; maxItems: number; item: { type: 'object'; fields: F };
13
+ messages?: { min?: string; max?: string };
14
+ }
15
+ export type ArrayBuilder<F extends Record<string, LeafDefinition>> = Omit<ArrayField<F>, 'label'> & {
16
+ label(text: string): ArrayBuilder<F>;
17
+ min(count: number, options?: MessageOption): ArrayBuilder<F>;
18
+ max(count: number, options?: MessageOption): ArrayBuilder<F>;
19
+ custom(validator: (rows: ReadonlyValue<RowValues<F>[]>) => void | string | Promise<void | string>): ArrayBuilder<F>;
20
+ };
21
+ export interface FormSchema { fields: Readonly<Record<string, LeafDefinition | ArrayField | ArrayBuilder<Record<string, LeafDefinition>>>>; }
22
+ export type ValueOf<F> = F extends { type: 'array'; item: { fields: infer R extends Record<string, LeafDefinition> } } ? RowValues<R>[]
23
+ : F extends { type: 'boolean' } ? boolean : F extends { type: 'record' } ? (F extends { objectSlug: infer O extends string } ? RecordRef & { objectSlug: O } : RecordRef) | null
24
+ : F extends { type: 'number' } ? number | null : F extends { type: 'plainDate' | 'dateTime' } ? string | null : string;
25
+ export type RowValues<F extends Record<string, LeafDefinition>> = { [K in keyof F]: ValueOf<F[K]> };
26
+ export type FormValues<S extends FormSchema> = { [K in keyof S['fields']]: ValueOf<S['fields'][K]> };
27
+ export type ArrayName<S extends FormSchema> = { [K in keyof S['fields']]: S['fields'][K]['type'] extends 'array' ? K : never }[keyof S['fields']] & string;
28
+ export type FieldName<S extends FormSchema, T extends FormField['type']> = {
29
+ [K in keyof S['fields'] & string]: S['fields'][K] extends { type: 'array'; item: { fields: infer F extends Record<string, LeafDefinition> } }
30
+ ? `${K}[${number}].${{ [P in keyof F & string]: F[P]['type'] extends T ? P : never }[keyof F & string]}`
31
+ : S['fields'][K]['type'] extends T ? K : never;
32
+ }[keyof S['fields'] & string];
33
+ export type FormFieldErrors<S extends FormSchema, E = string> = { [K in keyof S['fields']]?: S['fields'][K] extends { type: 'array'; item: { fields: infer F } }
34
+ ? (Partial<Record<keyof F, E>> | null)[] : E };
35
+ export type FormArrayErrors<S extends FormSchema> = Partial<Record<ArrayName<S>, string>>;
36
+ /** void 兼容既有提交;失败保留输入,字段名必须属于当前表单。 */
37
+ export type FormSubmitResult<S extends FormSchema> = void | { ok: true } | {
38
+ ok: false;
39
+ code: string;
40
+ message: string;
41
+ fieldErrors?: FormFieldErrors<S>;
42
+ arrayErrors?: FormArrayErrors<S>;
43
+ };
44
+
45
+ export type FormValidationResult<S extends FormSchema> = void | {
46
+ fieldErrors?: FormFieldErrors<S>;
47
+ arrayErrors?: FormArrayErrors<S>;
48
+ formError?: string;
49
+ };
50
+ type FieldValue<F extends FormField> = FormValues<{ fields: { value: F } }>['value'];
51
+ type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
52
+ export type FormOptions<S extends FormSchema> = {
53
+ schema: S;
54
+ initialValues: FormValues<S>;
55
+ validationMode?: 'onBlur' | 'onChange' | 'onSubmit';
56
+ validate?: (values: ReadonlyValue<FormValues<S>>) => FormValidationResult<S> | Promise<FormValidationResult<S>>;
57
+ };
58
+ type MessageOption = { message: string };
59
+ export type FieldBuilder<F extends FormField> = Omit<F, 'label'> & {
60
+ label(text: string): FieldBuilder<F>;
61
+ required(message?: string): FieldBuilder<F>;
62
+ optional(): FieldBuilder<F>;
63
+ custom(validator: (value: ReadonlyValue<FieldValue<F>>) => void | string | Promise<void | string>): FieldBuilder<F>;
64
+ } & (F['type'] extends 'string' | 'number' | 'plainDate' | 'dateTime' ? {
65
+ min(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
66
+ max(value: F['type'] extends 'string' | 'number' ? number : string, options?: MessageOption): FieldBuilder<F>;
67
+ } : {}) & (F['type'] extends 'string' ? {
68
+ email(options?: MessageOption): FieldBuilder<F>;
69
+ url(options?: MessageOption): FieldBuilder<F>;
70
+ } : {});
71
+ // builder 的 label 是方法,wire 的 label 是字符串;只有编译后才跨 Worker 边界。
72
+ export const Forms: {
73
+ schema<const F extends Record<string, LeafDefinition | ArrayBuilder<Record<string, LeafDefinition>>>>(fields: F): { readonly fields: F };
74
+ array<const F extends Record<string, LeafDefinition>>(fields: F): ArrayBuilder<F>;
75
+ string(): FieldBuilder<Extract<FormField, { type: 'string' }>>;
76
+ number(): FieldBuilder<Extract<FormField, { type: 'number' }>>;
77
+ boolean(): FieldBuilder<Extract<FormField, { type: 'boolean' }>>;
78
+ enum(options: readonly { value: string; label: string; description?: string }[]): FieldBuilder<Extract<FormField, { type: 'enum' }>>;
79
+ plainDate(): FieldBuilder<{ type: 'plainDate'; label: string }>;
80
+ dateTime(): FieldBuilder<{ type: 'dateTime'; label: string }>;
81
+ record<const O extends string>(objectSlug: O): FieldBuilder<{ type: 'record'; label: string; objectSlug: O }>;
82
+ };
@@ -0,0 +1,128 @@
1
+ import { isPlainDate, isDateTime } from './values.js';
2
+ import { recordObjectsForField } from './record-options.js';
3
+ import { freezeFormState } from './state.js';
4
+ import { createValidationCallback } from './validation.js';
5
+
6
+ import { validFieldKey, assertArrayCapacity } from './array-contract.js';
7
+
8
+ const descriptors = new WeakMap();
9
+ const fail = () => { throw new Error('runtime_form_schema_invalid'); };
10
+ const message = value => {
11
+ if (typeof value !== 'string' || !value.trim() || value.length > 300) fail();
12
+ return value;
13
+ };
14
+ function builder(field, validators = []) {
15
+ field = freezeFormState(field);
16
+ const next = (patch, checks = validators) => builder({ ...field, ...patch }, checks);
17
+ const rule = (key, value, options) => {
18
+ const messages = { ...field.messages };
19
+ if (options !== undefined) {
20
+ if (!options || Object.keys(options).some(key => key !== 'message')) fail();
21
+ messages[key] = message(options.message);
22
+ } else delete messages[key];
23
+ return next({ [field.type === 'array' ? `${key}Items` : field.type === 'string' ? `${key}Length` : key]: value, messages });
24
+ };
25
+ const methods = {
26
+ label(value) { return next({ label: value }); },
27
+ required(value) { return next({ required: true, messages: { ...field.messages, ...(value === undefined ? {} : { required: message(value) }) } }); },
28
+ optional() { return next({ required: false }); },
29
+ custom(validator) {
30
+ if (typeof validator !== 'function' || validators.length >= 4) fail();
31
+ return next({}, [...validators, validator]);
32
+ },
33
+ };
34
+ if (['array', 'string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
35
+ methods.min = (value, options) => rule('min', value, options);
36
+ methods.max = (value, options) => rule('max', value, options);
37
+ }
38
+ if (field.type === 'array') { delete methods.required; delete methods.optional; }
39
+ if (field.type === 'string') for (const format of ['email', 'url']) methods[format] = options => {
40
+ const messages = { ...field.messages };
41
+ if (options === undefined) delete messages.format;
42
+ else { if (!options || Object.keys(options).some(key => key !== 'message')) fail(); messages.format = message(options.message); }
43
+ return next({ format, messages });
44
+ };
45
+ const result = Object.freeze({ ...field, ...methods });
46
+ descriptors.set(result, { field, validators });
47
+ return result;
48
+ }
49
+ export const Forms = Object.freeze({
50
+ schema(fields) {
51
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) fail();
52
+ if (Object.values(fields).some(field => !descriptors.has(field))) fail();
53
+ return Object.freeze({ fields: Object.freeze({ ...fields }) });
54
+ },
55
+ ...Object.fromEntries(['string', 'number', 'boolean', 'plainDate', 'dateTime'].map(type => [type, () => builder({ type })])),
56
+ enum: options => builder({ type: 'enum', options: Array.isArray(options) ? options.map(option => Object.freeze({ ...option })) : options }),
57
+ array(fields) {
58
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields) || !Object.keys(fields).length || Object.keys(fields).length > 12 || Object.values(fields).some(field => !descriptors.has(field) || field.type === 'array')) fail();
59
+ return builder({ type: 'array', minItems: 0, maxItems: 50, item: { type: 'object', fields: { ...fields } } });
60
+ },
61
+ record: objectSlug => builder({ type: 'record', objectSlug }),
62
+ });
63
+
64
+ /** 只编译描述与回调;输入规则由 Host 单一校验器执行。 */
65
+ export function compileForm(options, formId, bindCallback = callback => callback) {
66
+ const { schema, validate, validationMode = 'onBlur' } = options;
67
+ if (!schema || !schema.fields || typeof schema.fields !== 'object' || Array.isArray(schema.fields) || Object.keys(schema).some(key => key !== 'fields')
68
+ || Object.values(schema.fields).filter(field => field.type !== 'array').length > 30 || Object.values(schema.fields).filter(field => field.type === 'array').length > 3 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
69
+ || (validate !== undefined && typeof validate !== 'function')) fail();
70
+ const fields = {}, validators = {};
71
+ for (const [name, input] of Object.entries(schema.fields)) {
72
+ if (!validFieldKey(name)) fail();
73
+ const descriptor = descriptors.get(input);
74
+ const source = descriptor?.field ?? input;
75
+ if (source?.type === 'array') {
76
+ if (Object.keys(source).some(key => !['type', 'label', 'minItems', 'maxItems', 'item', 'messages'].includes(key)) || typeof source.label !== 'string' || !source.label.trim() || source.label.length > 200
77
+ || !Number.isSafeInteger(source.minItems) || source.minItems < 0 || !Number.isSafeInteger(source.maxItems) || source.maxItems < 1 || source.maxItems > 50 || source.minItems > source.maxItems
78
+ || source.item?.type !== 'object' || Object.keys(source.item).some(key => !['type', 'fields'].includes(key)) || !source.item.fields || Object.values(source.item.fields).some(field => field.type === 'array')
79
+ || !Object.keys(source.item.fields).length || Object.keys(source.item.fields).length > 12) fail();
80
+ const child = compileForm({ schema: { fields: source.item.fields } }, formId, bindCallback);
81
+ fields[name] = { ...source, item: { type: 'object', fields: child.schema.fields } };
82
+ if (source.messages) { if (Object.keys(source.messages).some(key => !['min', 'max'].includes(key))) fail(); Object.values(source.messages).forEach(message); }
83
+ for (const [key, checks] of Object.entries(child.validators)) validators[`${name}[*].${key}`] = checks;
84
+ if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
85
+ continue;
86
+ }
87
+ const field = structuredClone(source);
88
+ if (!field || !['string', 'boolean', 'enum', 'number', 'plainDate', 'dateTime', 'record'].includes(field.type)
89
+ || typeof field.label !== 'string' || descriptor && !field.label.trim() || field.label.length > 200) fail();
90
+ const allowed = ['type', 'label', 'required', 'messages', ...(field.type === 'string' ? ['minLength', 'maxLength', 'format'] : field.type === 'enum' ? ['options'] : field.type === 'record' ? ['objectSlug', 'objects'] : ['number', 'plainDate', 'dateTime'].includes(field.type) ? ['min', 'max'] : [])];
91
+ if (Object.keys(field).some(key => !allowed.includes(key)) || (field.required !== undefined && typeof field.required !== 'boolean')) fail();
92
+ if (field.messages !== undefined) {
93
+ if (!field.messages || typeof field.messages !== 'object' || Array.isArray(field.messages)
94
+ || Object.keys(field.messages).some(key => !['required', 'min', 'max', 'format'].includes(key))) fail();
95
+ Object.values(field.messages).forEach(message);
96
+ }
97
+ if (field.type === 'record') recordObjectsForField(field);
98
+ if (field.type === 'enum') {
99
+ if (!Array.isArray(field.options) || !field.options.length || field.options.length > 100) fail();
100
+ const seen = new Set();
101
+ for (const choice of field.options) {
102
+ if (!choice || Object.keys(choice).some(key => !['value', 'label', 'description'].includes(key)) || typeof choice.value !== 'string' || !choice.value || choice.value.length > 200 || seen.has(choice.value) || typeof choice.label !== 'string' || choice.label.length > 200) fail();
103
+ if (choice.description !== undefined && (typeof choice.description !== 'string' || choice.description.length > 300)) fail();
104
+ seen.add(choice.value);
105
+ }
106
+ }
107
+ const low = field.type === 'string' ? 'minLength' : 'min', high = field.type === 'string' ? 'maxLength' : 'max';
108
+ const valid = value => field.type === 'string' ? Number.isSafeInteger(value) && value >= 0 && value <= 10000
109
+ : field.type === 'number' ? typeof value === 'number' && Number.isFinite(value)
110
+ : field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value);
111
+ for (const key of [low, high]) if (field[key] !== undefined && (!valid(field[key]) || key === 'maxLength' && field[key] === 0)) fail();
112
+ const comparable = value => field.type === 'dateTime' ? Date.parse(value) : value;
113
+ if (field[low] !== undefined && field[high] !== undefined && comparable(field[low]) > comparable(field[high])) fail();
114
+ if (field.format !== undefined && !['email', 'url'].includes(field.format)) fail();
115
+ fields[name] = field;
116
+ if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
117
+ }
118
+ const wire = freezeFormState({ fields });
119
+ const validation = { fields: Object.keys(validators), form: Boolean(validate), mode: validationMode };
120
+ const remote = Boolean(validation.fields.length || validate);
121
+ const configured = remote || options.validationMode !== undefined || Object.values(schema.fields).some(field => descriptors.has(field));
122
+ if (options.initialValues !== undefined) assertArrayCapacity(fields, options.initialValues);
123
+ const result = { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
124
+ onValidate: createValidationCallback(formId, fields, validators, validate ? bindCallback(validate) : undefined),
125
+ } : {}) };
126
+ Object.defineProperty(result, 'validators', { value: validators });
127
+ return result;
128
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FormSchema, FormValues, FormFieldErrors, FormArrayErrors } from './schema.js';
3
+ export type FormStateSelection<S extends FormSchema> = {
4
+ values?: true | readonly (keyof S['fields'] & string)[];
5
+ arrayErrors?: true | readonly (keyof FormArrayErrors<S> & string)[];
6
+ errors?: true | readonly (keyof S['fields'] & string)[];
7
+ submitting?: boolean;
8
+ validating?: boolean;
9
+ validatingFields?: true | readonly (keyof S['fields'] & string)[];
10
+ formError?: boolean;
11
+ };
12
+ type Names<S extends FormSchema, T> = T extends true ? keyof FormValues<S> : T extends readonly (infer K)[] ? Extract<K, keyof FormValues<S>> : never;
13
+ type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
14
+ export type SelectedFormState<S extends FormSchema, Selection extends FormStateSelection<S>> =
15
+ (Selection extends { values: infer T } ? { readonly values: ReadonlyValue<Pick<FormValues<S>, Names<S, T>>> } : {})
16
+ & (Selection extends { errors: infer T } ? { readonly errors: ReadonlyValue<Pick<FormFieldErrors<S>, Names<S, T>>> } : {})
17
+ & (Selection extends { arrayErrors: infer T } ? { readonly arrayErrors: Readonly<Pick<FormArrayErrors<S>, Extract<Names<S, T>, keyof FormArrayErrors<S>>>> } : {})
18
+ & (Selection extends { submitting: true } ? { readonly submitting: boolean } : {})
19
+ & (Selection extends { validating: true } ? { readonly validating: boolean } : {})
20
+ & (Selection extends { formError: true } ? { readonly formError: string | null } : {})
21
+ & (Selection extends { validatingFields: infer T } ? { readonly validatingFields: ReadonlyValue<Pick<FormFieldErrors<S, boolean>, Names<S, T>>> } : {});
22
+ export type WithStateComponent<S extends FormSchema> = <const Selection extends FormStateSelection<S>>(props: {
23
+ selection: Selection;
24
+ children: (state: SelectedFormState<S, Selection>) => ReactNode;
25
+ }) => ReactNode;
26
+
27
+ export type StateSelection = { arrayErrors?: true | readonly string[]; values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
28
+ export function selectedNames(selection: true | readonly string[] | undefined, fields: Record<string, unknown>): string[];
29
+ export function assertFormStateSelection(selection: unknown, fields: Record<string, unknown>): StateSelection;
30
+ export function assertFormStateSnapshot(snapshot: unknown, formId: string, fields: Record<string, unknown>, selection: StateSelection): unknown;
@@ -0,0 +1,82 @@
1
+ import { assertFieldValue, assertErrorTree, assertArrayErrors, normalizeFormValues, formBytes, ARRAY_LIMITS } from './array-contract.js';
2
+ import { assertJsonValue } from '../ui/validation.js';
3
+ const fail = () => { throw new Error('runtime_form_state_invalid'); };
4
+ const only = (value, keys) => {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
6
+ };
7
+ export function selectedNames(selection, fields) {
8
+ return selection === true ? Object.keys(fields) : Array.isArray(selection) ? selection : [];
9
+ }
10
+ export function assertFormStateSelection(selection, fields) {
11
+ only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'arrayErrors', 'formError']);
12
+ for (const kind of ['values', 'errors', 'validatingFields', 'arrayErrors']) {
13
+ const names = selection[kind];
14
+ if (names === undefined || names === true) continue;
15
+ if (!Array.isArray(names) || names.length > 33 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
16
+ if (kind === 'arrayErrors' && names.some(name => fields[name].type !== 'array')) fail();
17
+ }
18
+ for (const key of ['submitting', 'validating', 'formError']) if (selection[key] !== undefined && typeof selection[key] !== 'boolean') fail();
19
+ return selection;
20
+ }
21
+ export function assertFormStateSnapshot(snapshot, formId, fields, selection) {
22
+ assertJsonValue(snapshot, Object.values(fields).some(field => field.type === 'array') ? { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 } : undefined);
23
+ assertFormStateSelection(selection, fields);
24
+ const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.arrayErrors !== undefined ? ['arrayErrors'] : []), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
25
+ only(snapshot, keys);
26
+ if (Object.keys(snapshot).length !== keys.length || snapshot.formId !== formId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1) fail();
27
+ if (selection.values !== undefined) {
28
+ const names = selectedNames(selection.values, fields);
29
+ only(snapshot.values, names);
30
+ if (Object.keys(snapshot.values).length !== names.length) fail();
31
+ for (const name of names) assertFieldValue(snapshot.values[name], fields[name]);
32
+ }
33
+ if (selection.errors !== undefined) {
34
+ only(snapshot.errors, selectedNames(selection.errors, fields));
35
+ assertErrorTree(snapshot.errors, fields, snapshot.values);
36
+ }
37
+ for (const key of ['submitting', 'validating']) if (selection[key] && typeof snapshot[key] !== 'boolean') fail();
38
+ if (selection.formError && snapshot.formError !== null && (typeof snapshot.formError !== 'string' || !snapshot.formError || snapshot.formError.length > 4096)) fail();
39
+ if (selection.validatingFields !== undefined) {
40
+ only(snapshot.validatingFields, selectedNames(selection.validatingFields, fields));
41
+ assertErrorTree(snapshot.validatingFields, fields, snapshot.values, true);
42
+ }
43
+ if (selection.arrayErrors !== undefined) { only(snapshot.arrayErrors, selectedNames(selection.arrayErrors, fields)); assertArrayErrors(snapshot.arrayErrors, fields); }
44
+ if (Object.values(fields).some(field => field.type === 'array') && formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
45
+ return snapshot;
46
+ }
47
+ export function freezeFormState(value) {
48
+ if (value && typeof value === 'object') { Object.values(value).forEach(freezeFormState); Object.freeze(value); }
49
+ return value;
50
+ }
51
+ export function initialFormState(formId, fields, initialValues, selection) {
52
+ const snapshot = { formId, revision: 1 };
53
+ if (selection.values !== undefined) { const values = normalizeFormValues(fields, initialValues); snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name => [name, values[name]])); }
54
+ if (selection.errors !== undefined) snapshot.errors = {};
55
+ if (selection.arrayErrors !== undefined) snapshot.arrayErrors = {};
56
+ for (const key of ['submitting', 'validating']) if (selection[key]) snapshot[key] = false;
57
+ if (selection.formError) snapshot.formError = null;
58
+ if (selection.validatingFields !== undefined) snapshot.validatingFields = {};
59
+ assertFormStateSnapshot(snapshot, formId, fields, selection);
60
+ return snapshot;
61
+ }
62
+ export function createWithState({ createElement, Fragment, useMemo, useState, useRef }, formId, schema, initialValues) {
63
+ function Observer({ selection, children }) {
64
+ const revision = useRef(0);
65
+ const [snapshot, setSnapshot] = useState(() => initialFormState(formId, schema.fields, initialValues, selection));
66
+ const onState = useMemo(() => next => {
67
+ assertFormStateSnapshot(next, formId, schema.fields, selection);
68
+ if (next.revision <= revision.current) return;
69
+ revision.current = next.revision;
70
+ setSnapshot(freezeFormState(next));
71
+ }, []);
72
+ const { formId: _, revision: _revision, ...state } = snapshot;
73
+ return createElement(Fragment, null,
74
+ createElement('FormStateObserver', { formId, selection, onState }),
75
+ children(freezeFormState(state)));
76
+ }
77
+ return function WithState({ selection, children }) {
78
+ assertFormStateSelection(selection, schema.fields);
79
+ if (typeof children !== 'function') fail();
80
+ return createElement(Observer, { key: JSON.stringify(selection), selection, children });
81
+ };
82
+ }
@@ -0,0 +1,9 @@
1
+ export const VALIDATION_TIMEOUT_MS: 10000;
2
+ export interface ValidationMetadata { fields: string[]; form: boolean; mode: 'onBlur' | 'onChange' | 'onSubmit'; }
3
+ export interface ValidationRequest {
4
+ formId: string; runId: number; reason: 'blur' | 'change' | 'submit';
5
+ values: Record<string, unknown>; fields: string[]; includeForm: boolean;
6
+ }
7
+ export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string | (Record<string, string> | null)[]>; arrayErrors?: Record<string, string>; formError?: string; }
8
+ export function assertValidationMetadata(value: unknown, fields: Record<string, unknown>): ValidationMetadata;
9
+ export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'> & { values?: Record<string, unknown> }, fields: Record<string, unknown>): ValidationResponse;
@@ -0,0 +1,81 @@
1
+ import { assertFieldValue, assertArrayCapacity, assertErrorTree, assertArrayErrors, fieldAtPath, valueAtPath, rulePath, flattenErrors, nestErrors, formBytes, ARRAY_LIMITS } from './array-contract.js';
2
+ import { freezeFormState } from './state.js';
3
+ import { isDateTime, isPlainDate } from './values.js';
4
+ import { assertRecordRef, recordObjectsForField } from './record-options.js';
5
+
6
+ export const VALIDATION_TIMEOUT_MS = 10000;
7
+ const fail = () => { throw new Error('runtime_form_validation_invalid'); };
8
+ const only = (value, keys) => {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
10
+ };
11
+ const validMessage = value => typeof value === 'string' && value.trim() && value.length <= 300;
12
+ export function assertValidationMetadata(value, fields) {
13
+ only(value, ['fields', 'form', 'mode']);
14
+ if (!Array.isArray(value.fields) || value.fields.length > 69 || new Set(value.fields).size !== value.fields.length
15
+ || value.fields.some(name => { try { fieldAtPath(fields, typeof name === 'string' ? name.replace('[*]', '[0]') : name); return false; } catch { return true; } })
16
+ || typeof value.form !== 'boolean' || !['onBlur', 'onChange', 'onSubmit'].includes(value.mode)) fail();
17
+ return value;
18
+ }
19
+ export function assertValidationResponse(value, request, fields) {
20
+ only(value, ['formId', 'runId', 'fieldErrors', 'arrayErrors', 'formError']);
21
+ if (value.formId !== request.formId || value.runId !== request.runId) fail();
22
+ assertErrorTree(value.fieldErrors, fields, request.values, false, 300);
23
+ if (value.arrayErrors !== undefined) assertArrayErrors(value.arrayErrors, fields, 300);
24
+ if (value.formError !== undefined && !validMessage(value.formError)) fail();
25
+ if (Object.values(fields).some(field => field.type === 'array') && formBytes({ ...value, values: request.values }) > ARRAY_LIMITS.messageBytes - 32768) fail();
26
+ return value;
27
+ }
28
+ function assertRequest(request, formId, fields, validators, validate) {
29
+ only(request, ['formId', 'runId', 'reason', 'values', 'fields', 'includeForm']);
30
+ if (request.formId !== formId || !Number.isSafeInteger(request.runId) || request.runId < 1
31
+ || !['blur', 'change', 'submit'].includes(request.reason) || typeof request.includeForm !== 'boolean'
32
+ || request.includeForm && !validate || !Array.isArray(request.fields) || new Set(request.fields).size !== request.fields.length
33
+ || request.fields.length > 303 || request.fields.some(name => !Object.hasOwn(validators, rulePath(name)))) fail();
34
+ only(request.values, Object.keys(fields));
35
+ if (Object.keys(request.values).length !== Object.keys(fields).length) fail();
36
+ for (const [name, field] of Object.entries(fields)) assertFieldValue(request.values[name], field);
37
+ assertArrayCapacity(fields, request.values);
38
+ for (const path of request.fields) if (valueAtPath(request.values, path) === undefined) fail();
39
+ }
40
+ export function createValidationCallback(formId, fields, validators, validate) {
41
+ let busy = false;
42
+ return async input => {
43
+ assertRequest(input, formId, fields, validators, validate);
44
+ if (busy) fail();
45
+ busy = true;
46
+ const request = freezeFormState(structuredClone(input));
47
+ let active = true, timer, position = 0;
48
+ const fieldErrors = {}, arrayErrors = {};
49
+ const runField = async () => {
50
+ while (active && position < request.fields.length) {
51
+ const name = request.fields[position++], value = valueAtPath(request.values, name), field = fieldAtPath(fields, name);
52
+ if (!field.required && (value === '' || value === null)) continue;
53
+ for (const validator of validators[rulePath(name)]) {
54
+ if (!active) return;
55
+ const error = await validator(value);
56
+ if (!active) return;
57
+ if (error !== undefined) { if (!validMessage(error)) fail(); (field.type === 'array' ? arrayErrors : fieldErrors)[name] = error; break; }
58
+ }
59
+ }
60
+ };
61
+ try {
62
+ return await Promise.race([
63
+ (async () => {
64
+ await Promise.all([runField(), runField()]);
65
+ if (!active) return;
66
+ const result = request.includeForm ? await validate(request.values) : undefined;
67
+ if (!active) return;
68
+ if (result !== undefined) { only(result, ['fieldErrors', 'arrayErrors', 'formError']); }
69
+ if (result?.fieldErrors !== undefined) assertErrorTree(result.fieldErrors, fields, request.values, false, 300);
70
+ if (result?.arrayErrors !== undefined) assertArrayErrors(result.arrayErrors, fields, 300);
71
+ // 每个叶子合并,不能用整行覆盖同一行其他字段的跨字段错误。
72
+ const arrays = { ...result?.arrayErrors, ...arrayErrors };
73
+ const response = { formId, runId: request.runId, fieldErrors: nestErrors({ ...flattenErrors(result?.fieldErrors), ...fieldErrors }),
74
+ ...(Object.keys(arrays).length ? { arrayErrors: arrays } : {}), ...(result?.formError === undefined ? {} : { formError: result.formError }) };
75
+ return assertValidationResponse(response, request, fields);
76
+ })(),
77
+ new Promise((_, reject) => { timer = setTimeout(() => { active = false; reject(new Error('runtime_form_validation_timeout')); }, VALIDATION_TIMEOUT_MS); }),
78
+ ]);
79
+ } finally { active = false; busy = false; clearTimeout(timer); }
80
+ };
81
+ }
@@ -0,0 +1,5 @@
1
+ export function isPlainDate(value: unknown): value is string;
2
+ export function isDateTime(value: unknown): value is string;
3
+ export function parseNumberDraft(value: unknown): { value: number | null; error?: string };
4
+ export function instantToLocal(value: string | null): string;
5
+ export function localToInstant(value: string): { value: string | null; error?: string };