@octane-xplat/workshop 0.0.0-nightly.g14544fe
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/LICENSE +21 -0
- package/MOBILE.md +143 -0
- package/README.md +307 -0
- package/dist/build/discover.js +404 -0
- package/dist/build/macos-node.js +77 -0
- package/dist/build/mobile-node.js +96 -0
- package/dist/build/mobile-regex.js +40 -0
- package/dist/build/node.js +426 -0
- package/dist/build/vite-mobile.js +75 -0
- package/dist/build/vite.js +230 -0
- package/dist/build/windows/preflight.js +83 -0
- package/dist/contract.js +50 -0
- package/dist/index.js +2 -0
- package/package.json +127 -0
- package/src/build/discover.ts +585 -0
- package/src/build/macos/shell.macos.ts +674 -0
- package/src/build/macos-node.ts +98 -0
- package/src/build/macos-vite.ts +48 -0
- package/src/build/mobile-node.ts +127 -0
- package/src/build/mobile-regex.ts +60 -0
- package/src/build/node.d.ts +65 -0
- package/src/build/node.ts +537 -0
- package/src/build/vite-mobile.d.ts +21 -0
- package/src/build/vite-mobile.ts +102 -0
- package/src/build/vite.d.ts +11 -0
- package/src/build/vite.ts +316 -0
- package/src/build/windows/preflight.ts +113 -0
- package/src/catalog.ts +58 -0
- package/src/contract.ts +257 -0
- package/src/controls.ts +159 -0
- package/src/errors.ts +47 -0
- package/src/fixture.ts +158 -0
- package/src/index.ts +6 -0
- package/src/macos/controller.ts +170 -0
- package/src/macos/index.macos.ts +2 -0
- package/src/macos/stage.macos.ts +182 -0
- package/src/mobile/controller.ts +134 -0
- package/src/mobile/entry.mobile.ts +607 -0
- package/src/mobile/index.mobile.ts +3 -0
- package/src/mobile/mount.mobile.ts +60 -0
- package/src/mobile/tree.mobile.tsrx +17 -0
- package/src/session.ts +520 -0
- package/src/web/entry.web.ts +270 -0
- package/src/web/index.web.ts +7 -0
- package/src/web/mount.web.ts +86 -0
- package/src/web/shell.css +449 -0
- package/src/web/shell.web.tsrx +759 -0
- package/types/generated/.tsrx-typegen-manifest.json +24 -0
- package/types/generated/catalog.d.ts +30 -0
- package/types/generated/contract.d.ts +145 -0
- package/types/generated/controls.d.ts +28 -0
- package/types/generated/errors.d.ts +13 -0
- package/types/generated/fixture.d.ts +53 -0
- package/types/generated/index.d.ts +6 -0
- package/types/generated/macos/controller.d.ts +52 -0
- package/types/generated/macos/index.macos.d.ts +2 -0
- package/types/generated/macos/stage.macos.d.ts +10 -0
- package/types/generated/mobile/controller.d.ts +31 -0
- package/types/generated/mobile/entry.mobile.d.ts +22 -0
- package/types/generated/mobile/index.mobile.d.ts +3 -0
- package/types/generated/mobile/mount.mobile.d.ts +4 -0
- package/types/generated/mobile/tree.mobile.d.ts +4 -0
- package/types/generated/session.d.ts +84 -0
- package/types/generated/web/entry.web.d.ts +53 -0
- package/types/generated/web/index.web.d.ts +7 -0
- package/types/generated/web/mount.web.d.ts +2 -0
- package/types/generated/web/shell.web.d.ts +4 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @octane-xplat/workshop — typed authoring contract.
|
|
3
|
+
*
|
|
4
|
+
* One `defineStory` declaration describes an isolated development run of an
|
|
5
|
+
* Octane component: stable identity, target applicability, default args,
|
|
6
|
+
* explicit controls, fresh state, a cancellable async fixture, and ordered
|
|
7
|
+
* provider wrappers. The story file compiles under the consumer's normal
|
|
8
|
+
* renderer rules; this module carries no renderer or host globals itself.
|
|
9
|
+
*/
|
|
10
|
+
import type { Store } from '@octane-xplat/ui/data'
|
|
11
|
+
|
|
12
|
+
/** The six product targets. Only `web` is qualified by the current release. */
|
|
13
|
+
export type WorkshopTarget = 'web' | 'ios' | 'android' | 'macos' | 'windows' | 'linux'
|
|
14
|
+
|
|
15
|
+
export const WORKSHOP_TARGETS: readonly WorkshopTarget[] = [
|
|
16
|
+
'web',
|
|
17
|
+
'ios',
|
|
18
|
+
'android',
|
|
19
|
+
'macos',
|
|
20
|
+
'windows',
|
|
21
|
+
'linux',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** Version of the authoring contract — bumped on breaking changes. */
|
|
25
|
+
export const WORKSHOP_CONTRACT_VERSION = 1
|
|
26
|
+
|
|
27
|
+
export type StoryTargets = 'all' | readonly WorkshopTarget[]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Portable cancellation contract for fixture work. The host adapter supplies
|
|
31
|
+
* an implementation with equivalent observable semantics — do not rely on
|
|
32
|
+
* `AbortController`/`signal.throwIfAborted` being the underlying mechanism.
|
|
33
|
+
*/
|
|
34
|
+
export interface CancellationSignal {
|
|
35
|
+
readonly aborted: boolean
|
|
36
|
+
addEventListener(type: 'abort', listener: () => void): void
|
|
37
|
+
removeEventListener(type: 'abort', listener: () => void): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type StoryCleanup = () => void | Promise<void>
|
|
41
|
+
|
|
42
|
+
/** Context handed to the fixture factory for each story generation. */
|
|
43
|
+
export interface FixtureContext {
|
|
44
|
+
/** The real target this run mounts on. */
|
|
45
|
+
readonly target: WorkshopTarget
|
|
46
|
+
/** Capabilities declared by the story. */
|
|
47
|
+
readonly capabilities: readonly string[]
|
|
48
|
+
/** Observable cancellation; aborted when the generation retires. */
|
|
49
|
+
readonly signal: CancellationSignal
|
|
50
|
+
/**
|
|
51
|
+
* Register an owned resource for disposal. Disposal runs exactly once —
|
|
52
|
+
* including for resources registered after cancellation, which are
|
|
53
|
+
* released as soon as the fixture settles.
|
|
54
|
+
*/
|
|
55
|
+
onCleanup(cleanup: StoryCleanup): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FixtureFactory<F> = (context: FixtureContext) => F | Promise<F>
|
|
59
|
+
|
|
60
|
+
/** A compiler-produced Octane component. Kept opaque to transport/tooling. */
|
|
61
|
+
export type StoryComponent<P> = (props: P) => unknown
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An ordered provider wrapper. Consumer presets wrap story providers;
|
|
65
|
+
* each receives the typed fixture result and `children`.
|
|
66
|
+
*/
|
|
67
|
+
export type StoryProvider<F> = (props: { fixture: F; children?: unknown }) => unknown
|
|
68
|
+
|
|
69
|
+
export interface BooleanControl {
|
|
70
|
+
type: 'boolean'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface StringControl {
|
|
74
|
+
type: 'string'
|
|
75
|
+
placeholder?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface NumberControl {
|
|
79
|
+
type: 'number'
|
|
80
|
+
min?: number
|
|
81
|
+
max?: number
|
|
82
|
+
step?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface EnumControl<T> {
|
|
86
|
+
type: 'enum'
|
|
87
|
+
options: readonly T[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type ControlValue<T> = 0 extends 1 & T
|
|
91
|
+
? BooleanControl | NumberControl | StringControl | EnumControl<T>
|
|
92
|
+
: [T] extends [boolean]
|
|
93
|
+
? BooleanControl
|
|
94
|
+
: [T] extends [number]
|
|
95
|
+
? NumberControl | EnumControl<T>
|
|
96
|
+
: [T] extends [string]
|
|
97
|
+
? StringControl | EnumControl<T>
|
|
98
|
+
: never
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Controls may be declared only for explicitly declared, portable-valued
|
|
102
|
+
* props. `allowUnset` is legal only on optional props — Unset removes the
|
|
103
|
+
* key from the next props bag rather than merging a stale value.
|
|
104
|
+
*/
|
|
105
|
+
export type StoryControl<T, Optional extends boolean> = ControlValue<T> &
|
|
106
|
+
(Optional extends true ? { allowUnset?: boolean } : { allowUnset?: false })
|
|
107
|
+
|
|
108
|
+
export type StoryControls<P> = {
|
|
109
|
+
[K in keyof P]?: StoryControl<NonNullable<P[K]>, undefined extends P[K] ? true : false>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Props the runner injects beyond `args`: fresh state store + fixture. */
|
|
113
|
+
export type InjectedStoryProps<S, F> = (S extends void ? unknown : { state: Store<S> }) &
|
|
114
|
+
(F extends void ? unknown : { fixture: F })
|
|
115
|
+
|
|
116
|
+
type ReservedStoryKeys = 'state' | 'fixture'
|
|
117
|
+
|
|
118
|
+
/** Reserved injected prop names cannot collide with declared props. */
|
|
119
|
+
type PropsWithoutReserved<P> = Extract<keyof P, ReservedStoryKeys> extends never ? P : never
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A complete story declaration. `id`, `title`, `targets`, and `capabilities`
|
|
123
|
+
* must be literal values — discovery reads them statically without
|
|
124
|
+
* evaluating the module.
|
|
125
|
+
*/
|
|
126
|
+
export type StoryDefinition<P = Record<string, never>, S = void, F = void> = {
|
|
127
|
+
/** Stable, unique story id — e.g. 'account/profile-card'. */
|
|
128
|
+
id: string
|
|
129
|
+
/** Display title; '/' segments group the catalog. */
|
|
130
|
+
title: string
|
|
131
|
+
/** Targets this story can mount on; 'all' covers the six product targets. */
|
|
132
|
+
targets: StoryTargets
|
|
133
|
+
/** Host capabilities the story requires (e.g. 'camera', 'network-write'). */
|
|
134
|
+
capabilities?: readonly string[]
|
|
135
|
+
/** The component under development. */
|
|
136
|
+
component: StoryComponent<PropsWithoutReserved<P> & InjectedStoryProps<S, F>>
|
|
137
|
+
/** Immutable default props for each run; cloned fresh per generation. */
|
|
138
|
+
args: PropsWithoutReserved<P>
|
|
139
|
+
/** Explicit editable inputs for declared props. */
|
|
140
|
+
controls?: StoryControls<P>
|
|
141
|
+
/** Fixture preparation budget in milliseconds; defaults to 10 000. */
|
|
142
|
+
fixtureTimeout?: number
|
|
143
|
+
/** Ordered wrappers applied around the component for each generation. */
|
|
144
|
+
providers?: readonly StoryProvider<F extends void ? undefined : F>[]
|
|
145
|
+
} & (S extends void ? { state?: never } : { state: () => S }) &
|
|
146
|
+
(F extends void ? { fixture?: never } : { fixture: FixtureFactory<F> })
|
|
147
|
+
|
|
148
|
+
/** Declare a story. Validates cheap authoring rules eagerly. */
|
|
149
|
+
export function defineStory<P = Record<string, never>, S = void, F = void>(
|
|
150
|
+
definition: StoryDefinition<P, S, F>,
|
|
151
|
+
): StoryDefinition<P, S, F> {
|
|
152
|
+
assertLiteralAuthoring(definition)
|
|
153
|
+
return definition
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertLiteralAuthoring(definition: StoryDefinition<any, any, any>): void {
|
|
157
|
+
const fail = (message: string): never => {
|
|
158
|
+
throw new Error(`defineStory: ${message}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof definition?.id !== 'string' || !definition.id) {
|
|
162
|
+
fail('a story requires a non-empty string id')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof definition.title !== 'string' || !definition.title) {
|
|
166
|
+
fail(`story "${definition.id}" requires a non-empty string title`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const targets = definition.targets
|
|
170
|
+
const valid =
|
|
171
|
+
targets === 'all' ||
|
|
172
|
+
(Array.isArray(targets) &&
|
|
173
|
+
targets.length > 0 &&
|
|
174
|
+
targets.every((t) => WORKSHOP_TARGETS.includes(t)))
|
|
175
|
+
|
|
176
|
+
if (!valid) {
|
|
177
|
+
fail(
|
|
178
|
+
`story "${definition.id}" — targets must be 'all' or a subset of: ${WORKSHOP_TARGETS.join(', ')}`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (typeof definition.component !== 'function') {
|
|
183
|
+
fail(`story "${definition.id}" — component must be an Octane component`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (definition.args == null || typeof definition.args !== 'object') {
|
|
187
|
+
fail(`story "${definition.id}" — args must be an object`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if ('state' in definition && typeof definition.state !== 'function') {
|
|
191
|
+
fail(`story "${definition.id}" — state must be a factory function`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if ('fixture' in definition && typeof definition.fixture !== 'function') {
|
|
195
|
+
fail(`story "${definition.id}" — fixture must be a factory function`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (definition.args && typeof definition.args === 'object') {
|
|
199
|
+
for (const key of ['state', 'fixture'] as const) {
|
|
200
|
+
if (key in definition.args) {
|
|
201
|
+
fail(`story "${definition.id}" — args may not declare reserved injected prop "${key}"`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (definition.controls) {
|
|
207
|
+
for (const [key, control] of Object.entries(definition.controls)) {
|
|
208
|
+
if (!control || typeof control !== 'object') {
|
|
209
|
+
fail(`story "${definition.id}" — control "${key}" must be an object`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const type = (control as { type?: string }).type
|
|
213
|
+
if (!['boolean', 'string', 'number', 'enum'].includes(type ?? '')) {
|
|
214
|
+
fail(
|
|
215
|
+
`story "${definition.id}" — control "${key}" has unknown type "${type}" (expected boolean, string, number or enum)`,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (type === 'enum' && !Array.isArray((control as EnumControl<unknown>).options)) {
|
|
220
|
+
fail(`story "${definition.id}" — enum control "${key}" requires an options array`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
type === 'number' &&
|
|
225
|
+
typeof (control as NumberControl).min === 'number' &&
|
|
226
|
+
typeof (control as NumberControl).max === 'number' &&
|
|
227
|
+
(control as NumberControl).min! > (control as NumberControl).max!
|
|
228
|
+
) {
|
|
229
|
+
fail(`story "${definition.id}" — number control "${key}" has min > max`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (definition.providers !== undefined && !Array.isArray(definition.providers)) {
|
|
235
|
+
fail(`story "${definition.id}" — providers must be an array`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Consumer workshop configuration, loaded by the dev tooling. */
|
|
240
|
+
export interface WorkshopConfig {
|
|
241
|
+
stories?: {
|
|
242
|
+
/** Glob(s) matching story modules. Default: '**/*.stories.{ts,tsx,tsrx}' plus platform-suffixed leaves. */
|
|
243
|
+
include?: string | readonly string[]
|
|
244
|
+
exclude?: string | readonly string[]
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Module specifiers (relative to the config file) whose `providers` or
|
|
248
|
+
* `default` export is an array of provider components wrapping every story.
|
|
249
|
+
*/
|
|
250
|
+
providerModules?: readonly string[]
|
|
251
|
+
/** Extra static asset roots served under /@workshop-assets/<name>/. */
|
|
252
|
+
assets?: Record<string, string>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function defineWorkshopConfig(config: WorkshopConfig): WorkshopConfig {
|
|
256
|
+
return config
|
|
257
|
+
}
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control application helpers — host-agnostic. The next props bag is always
|
|
3
|
+
* complete: Unset removes the key entirely; removed keys reach the renderer
|
|
4
|
+
* as explicit `undefined` so nothing stale merges forward.
|
|
5
|
+
*/
|
|
6
|
+
import type { StoryDefinition } from './contract'
|
|
7
|
+
|
|
8
|
+
export const UNSET = Symbol.for('xplat-workshop.unset')
|
|
9
|
+
export type ControlValueInput = boolean | string | number | typeof UNSET
|
|
10
|
+
|
|
11
|
+
export interface FieldError {
|
|
12
|
+
message: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isUnsettable(definition: StoryDefinition<any, any, any>, key: string): boolean {
|
|
16
|
+
const control = definition.controls?.[key] as { allowUnset?: boolean } | undefined
|
|
17
|
+
if (!control) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return control.allowUnset === true || !(key in (definition.args ?? {}))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Clone default args per generation — nested data must not leak across runs. */
|
|
25
|
+
export function freshArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
26
|
+
const clone = (value: unknown): unknown => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.map(clone)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (value !== null && typeof value === 'object') {
|
|
32
|
+
const proto = Object.getPrototypeOf(value)
|
|
33
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
34
|
+
return value // class instances / stores: pass through, do not clone
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const out: Record<string, unknown> = {}
|
|
38
|
+
for (const [k, v] of Object.entries(value)) {
|
|
39
|
+
out[k] = clone(v)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return value // functions and primitives pass through
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return clone(args) as Record<string, unknown>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate and fold a control input into the next props bag. Returns either
|
|
53
|
+
* the next complete bag or a per-field error; invalid input never mutates.
|
|
54
|
+
*/
|
|
55
|
+
export function applyControlInput(
|
|
56
|
+
definition: StoryDefinition<any, any, any>,
|
|
57
|
+
currentProps: Record<string, unknown>,
|
|
58
|
+
key: string,
|
|
59
|
+
raw: string | boolean | number | typeof UNSET,
|
|
60
|
+
): { props?: Record<string, unknown>; error?: FieldError } {
|
|
61
|
+
const control = definition.controls?.[key] as
|
|
62
|
+
| {
|
|
63
|
+
type: string
|
|
64
|
+
min?: number
|
|
65
|
+
max?: number
|
|
66
|
+
options?: readonly unknown[]
|
|
67
|
+
allowUnset?: boolean
|
|
68
|
+
}
|
|
69
|
+
| undefined
|
|
70
|
+
|
|
71
|
+
if (!control) {
|
|
72
|
+
return { error: { message: `no control declared for "${key}"` } }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const next = { ...currentProps }
|
|
76
|
+
if (raw === UNSET) {
|
|
77
|
+
if (!isUnsettable(definition, key)) {
|
|
78
|
+
return { error: { message: `"${key}" is a required prop and cannot be unset` } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
delete next[key]
|
|
82
|
+
return { props: next }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (control.type) {
|
|
86
|
+
case 'boolean': {
|
|
87
|
+
if (typeof raw !== 'boolean') {
|
|
88
|
+
return { error: { message: 'expected a boolean' } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
next[key] = raw
|
|
92
|
+
return { props: next }
|
|
93
|
+
}
|
|
94
|
+
case 'string': {
|
|
95
|
+
if (typeof raw !== 'string') {
|
|
96
|
+
return { error: { message: 'expected a string' } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
next[key] = raw
|
|
100
|
+
return { props: next }
|
|
101
|
+
}
|
|
102
|
+
case 'number': {
|
|
103
|
+
const value =
|
|
104
|
+
typeof raw === 'number'
|
|
105
|
+
? raw
|
|
106
|
+
: typeof raw === 'string' && raw.trim() === ''
|
|
107
|
+
? NaN
|
|
108
|
+
: Number(raw)
|
|
109
|
+
|
|
110
|
+
if (!Number.isFinite(value)) {
|
|
111
|
+
return { error: { message: `"${key}" needs a finite number` } }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (control.min !== undefined && value < control.min) {
|
|
115
|
+
return { error: { message: `"${key}" must be ≥ ${control.min}` } }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (control.max !== undefined && value > control.max) {
|
|
119
|
+
return { error: { message: `"${key}" must be ≤ ${control.max}` } }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
next[key] = value
|
|
123
|
+
return { props: next }
|
|
124
|
+
}
|
|
125
|
+
case 'enum': {
|
|
126
|
+
if (!(control.options ?? []).some((option) => Object.is(option, raw))) {
|
|
127
|
+
return {
|
|
128
|
+
error: {
|
|
129
|
+
message: `"${key}" must be one of: ${(control.options ?? []).map(String).join(', ')}`,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
next[key] = raw
|
|
135
|
+
return { props: next }
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
return { error: { message: `unsupported control type "${control.type}"` } }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Produce the props object handed to the renderer: next bag plus explicit
|
|
144
|
+
* `undefined` for every key present before and absent now — removal is
|
|
145
|
+
* visible to hosts that reconcile keys rather than whole objects.
|
|
146
|
+
*/
|
|
147
|
+
export function reconcileProps(
|
|
148
|
+
previous: Record<string, unknown>,
|
|
149
|
+
next: Record<string, unknown>,
|
|
150
|
+
): Record<string, unknown> {
|
|
151
|
+
const merged: Record<string, unknown> = { ...next }
|
|
152
|
+
for (const key of Object.keys(previous)) {
|
|
153
|
+
if (!(key in next)) {
|
|
154
|
+
merged[key] = undefined
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return merged
|
|
159
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Failure phases surfaced by the workshop shell and CLI. */
|
|
2
|
+
export type WorkshopErrorPhase =
|
|
3
|
+
| 'discovery'
|
|
4
|
+
| 'compile'
|
|
5
|
+
| 'fixture'
|
|
6
|
+
| 'mount'
|
|
7
|
+
| 'interaction'
|
|
8
|
+
| 'cleanup'
|
|
9
|
+
| 'launch'
|
|
10
|
+
| 'connection'
|
|
11
|
+
|
|
12
|
+
export class WorkshopError extends Error {
|
|
13
|
+
readonly phase: WorkshopErrorPhase
|
|
14
|
+
readonly storyId?: string
|
|
15
|
+
readonly sourceLocation?: string
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
phase: WorkshopErrorPhase,
|
|
19
|
+
message: string,
|
|
20
|
+
options?: { storyId?: string; cause?: unknown; sourceLocation?: string },
|
|
21
|
+
) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'WorkshopError'
|
|
24
|
+
this.phase = phase
|
|
25
|
+
this.storyId = options?.storyId
|
|
26
|
+
this.sourceLocation = options?.sourceLocation
|
|
27
|
+
if (options?.cause !== undefined) {
|
|
28
|
+
;(this as { cause?: unknown }).cause = options.cause
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function describeCause(cause: unknown): string {
|
|
34
|
+
if (cause instanceof Error) {
|
|
35
|
+
// JSC stacks often start directly at a `fn@file:line` frame without the
|
|
36
|
+
// `Name: message` head — keep the message visible either way.
|
|
37
|
+
const head = `${cause.name}: ${cause.message}`
|
|
38
|
+
const tail = cause.stack?.split('\n').slice(0, 3).join('\n')
|
|
39
|
+
if (!tail) {
|
|
40
|
+
return head
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tail.startsWith(head) ? tail : `${head}\n${tail}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return String(cause)
|
|
47
|
+
}
|
package/src/fixture.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owned-resource and cancellation machinery for one story generation.
|
|
3
|
+
* Host-agnostic: no DOM globals, no AbortController — the portable
|
|
4
|
+
* CancellationSignal contract only.
|
|
5
|
+
*/
|
|
6
|
+
import type { CancellationSignal, FixtureContext, StoryCleanup, WorkshopTarget } from './contract'
|
|
7
|
+
|
|
8
|
+
/** The default fixture preparation budget (per-story override: fixtureTimeout). */
|
|
9
|
+
export const DEFAULT_FIXTURE_TIMEOUT = 10_000
|
|
10
|
+
/** Bound on owned cleanup before declaring a restart-required state. */
|
|
11
|
+
export const DEFAULT_DISPOSAL_TIMEOUT = 10_000
|
|
12
|
+
|
|
13
|
+
/** Rejection used to interrupt awaits when a generation is cancelled. */
|
|
14
|
+
export class CancelledError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('workshop generation cancelled')
|
|
17
|
+
this.name = 'CancelledError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Cancellation implements CancellationSignal {
|
|
22
|
+
aborted = false
|
|
23
|
+
private readonly listeners = new Set<() => void>()
|
|
24
|
+
private rejectPromise: ((error: Error) => void) | undefined
|
|
25
|
+
|
|
26
|
+
/** Rejects as soon as this generation is cancelled — race it against
|
|
27
|
+
* fixture work so a superseded selection never waits for a dead run. */
|
|
28
|
+
readonly promise = new Promise<never>((_resolve, reject) => {
|
|
29
|
+
this.rejectPromise = reject
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
// Cancellation can land while nothing is racing the promise — keep it
|
|
34
|
+
// counted as handled so it never reports an unhandled rejection.
|
|
35
|
+
this.promise.catch(() => {})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cancel(): void {
|
|
39
|
+
this.rejectPromise?.(new CancelledError())
|
|
40
|
+
if (this.aborted) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.aborted = true
|
|
45
|
+
for (const listener of this.listeners) {
|
|
46
|
+
try {
|
|
47
|
+
listener()
|
|
48
|
+
} catch {
|
|
49
|
+
// Listener failures must not stop sibling notifications.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.listeners.clear()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addEventListener(_type: 'abort', listener: () => void): void {
|
|
57
|
+
if (this.aborted) {
|
|
58
|
+
listener()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.listeners.add(listener)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
removeEventListener(_type: 'abort', listener: () => void): void {
|
|
66
|
+
this.listeners.delete(listener)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resources a fixture or provider registered for cleanup. Disposal runs each
|
|
72
|
+
* cleanup exactly once, attempts every entry even after a failure, and —
|
|
73
|
+
* after settle() — immediately disposes anything registered late (a fixture
|
|
74
|
+
* that ignored cancellation still has its resources released).
|
|
75
|
+
*/
|
|
76
|
+
export class OwnedResources {
|
|
77
|
+
private readonly cleanups: StoryCleanup[] = []
|
|
78
|
+
private settled = false
|
|
79
|
+
private pending = Promise.resolve()
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly onLateFailure?: (error: unknown) => void,
|
|
82
|
+
private readonly lateTimeout = DEFAULT_DISPOSAL_TIMEOUT,
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
readonly failures: { cleanup: StoryCleanup; error: unknown }[] = []
|
|
86
|
+
|
|
87
|
+
register(cleanup: StoryCleanup): void {
|
|
88
|
+
if (this.settled) {
|
|
89
|
+
// Registered after cancellation/settlement: dispose exactly once, now.
|
|
90
|
+
this.pending = Promise.all([this.pending, this.runOne(cleanup, true)]).then(() => {})
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.cleanups.push(cleanup)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async runOne(cleanup: StoryCleanup, late = false): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
const work = cleanup()
|
|
100
|
+
await (late ? withTimeout(Promise.resolve(work), this.lateTimeout, () => {}) : work)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
this.failures.push({ cleanup, error })
|
|
103
|
+
if (late) {
|
|
104
|
+
this.onLateFailure?.(error)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Unwind in reverse registration order; every entry is attempted. */
|
|
110
|
+
async dispose(): Promise<{ errors: unknown[] }> {
|
|
111
|
+
this.settled = true
|
|
112
|
+
const entries = this.cleanups.splice(0)
|
|
113
|
+
// Start every cleanup in reverse ownership order. A hung cleanup must
|
|
114
|
+
// not prevent siblings from being attempted before the global timeout.
|
|
115
|
+
await Promise.all(entries.reverse().map((cleanup) => this.runOne(cleanup)))
|
|
116
|
+
|
|
117
|
+
await this.pending
|
|
118
|
+
return { errors: this.failures.map((f) => f.error) }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createFixtureContext(
|
|
123
|
+
target: WorkshopTarget,
|
|
124
|
+
capabilities: readonly string[],
|
|
125
|
+
cancellation: Cancellation,
|
|
126
|
+
resources: OwnedResources,
|
|
127
|
+
): FixtureContext {
|
|
128
|
+
return {
|
|
129
|
+
target,
|
|
130
|
+
capabilities,
|
|
131
|
+
signal: cancellation,
|
|
132
|
+
onCleanup: (cleanup) => resources.register(cleanup),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Race a value/promise against a budget; calls onTimeout before rejecting. */
|
|
137
|
+
export async function withTimeout<T>(
|
|
138
|
+
value: T | Promise<T>,
|
|
139
|
+
ms: number,
|
|
140
|
+
onTimeout: () => void,
|
|
141
|
+
): Promise<T> {
|
|
142
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
143
|
+
try {
|
|
144
|
+
return await Promise.race([
|
|
145
|
+
Promise.resolve(value),
|
|
146
|
+
new Promise<never>((_resolve, reject) => {
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
onTimeout()
|
|
149
|
+
reject(new Error(`timed out after ${ms}ms`))
|
|
150
|
+
}, ms)
|
|
151
|
+
}),
|
|
152
|
+
])
|
|
153
|
+
} finally {
|
|
154
|
+
if (timer !== undefined) {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|