@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +307 -0
  4. package/dist/build/discover.js +404 -0
  5. package/dist/build/macos-node.js +77 -0
  6. package/dist/build/mobile-node.js +96 -0
  7. package/dist/build/mobile-regex.js +40 -0
  8. package/dist/build/node.js +426 -0
  9. package/dist/build/vite-mobile.js +75 -0
  10. package/dist/build/vite.js +230 -0
  11. package/dist/build/windows/preflight.js +83 -0
  12. package/dist/contract.js +50 -0
  13. package/dist/index.js +2 -0
  14. package/package.json +127 -0
  15. package/src/build/discover.ts +585 -0
  16. package/src/build/macos/shell.macos.ts +674 -0
  17. package/src/build/macos-node.ts +98 -0
  18. package/src/build/macos-vite.ts +48 -0
  19. package/src/build/mobile-node.ts +127 -0
  20. package/src/build/mobile-regex.ts +60 -0
  21. package/src/build/node.d.ts +65 -0
  22. package/src/build/node.ts +537 -0
  23. package/src/build/vite-mobile.d.ts +21 -0
  24. package/src/build/vite-mobile.ts +102 -0
  25. package/src/build/vite.d.ts +11 -0
  26. package/src/build/vite.ts +316 -0
  27. package/src/build/windows/preflight.ts +113 -0
  28. package/src/catalog.ts +58 -0
  29. package/src/contract.ts +257 -0
  30. package/src/controls.ts +159 -0
  31. package/src/errors.ts +47 -0
  32. package/src/fixture.ts +158 -0
  33. package/src/index.ts +6 -0
  34. package/src/macos/controller.ts +170 -0
  35. package/src/macos/index.macos.ts +2 -0
  36. package/src/macos/stage.macos.ts +182 -0
  37. package/src/mobile/controller.ts +134 -0
  38. package/src/mobile/entry.mobile.ts +607 -0
  39. package/src/mobile/index.mobile.ts +3 -0
  40. package/src/mobile/mount.mobile.ts +60 -0
  41. package/src/mobile/tree.mobile.tsrx +17 -0
  42. package/src/session.ts +520 -0
  43. package/src/web/entry.web.ts +270 -0
  44. package/src/web/index.web.ts +7 -0
  45. package/src/web/mount.web.ts +86 -0
  46. package/src/web/shell.css +449 -0
  47. package/src/web/shell.web.tsrx +759 -0
  48. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  49. package/types/generated/catalog.d.ts +30 -0
  50. package/types/generated/contract.d.ts +145 -0
  51. package/types/generated/controls.d.ts +28 -0
  52. package/types/generated/errors.d.ts +13 -0
  53. package/types/generated/fixture.d.ts +53 -0
  54. package/types/generated/index.d.ts +6 -0
  55. package/types/generated/macos/controller.d.ts +52 -0
  56. package/types/generated/macos/index.macos.d.ts +2 -0
  57. package/types/generated/macos/stage.macos.d.ts +10 -0
  58. package/types/generated/mobile/controller.d.ts +31 -0
  59. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  60. package/types/generated/mobile/index.mobile.d.ts +3 -0
  61. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  62. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  63. package/types/generated/session.d.ts +84 -0
  64. package/types/generated/web/entry.web.d.ts +53 -0
  65. package/types/generated/web/index.web.d.ts +7 -0
  66. package/types/generated/web/mount.web.d.ts +2 -0
  67. package/types/generated/web/shell.web.d.ts +4 -0
@@ -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: '**&#47;*.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
+ }
@@ -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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @octane-xplat/workshop — public authoring surface.
3
+ * The contract lives in contract.ts so generated declarations can rewrite
4
+ * sibling specifiers without index-path ambiguity.
5
+ */
6
+ export * from './contract'