@octane-xplat/workshop 0.1.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +305 -1
  4. package/dist/node/index.js +50 -0
  5. package/package.json +118 -5
  6. package/src/catalog.ts +58 -0
  7. package/src/contract.ts +257 -0
  8. package/src/controls.ts +159 -0
  9. package/src/discover.mjs +584 -0
  10. package/src/errors.ts +47 -0
  11. package/src/fixture.ts +158 -0
  12. package/src/index.ts +6 -0
  13. package/src/macos/controller.ts +170 -0
  14. package/src/macos/index.macos.ts +2 -0
  15. package/src/macos/shell.macos.mjs +673 -0
  16. package/src/macos/stage.macos.ts +182 -0
  17. package/src/macos-node.mjs +95 -0
  18. package/src/macos-vite.mjs +47 -0
  19. package/src/mobile/controller.ts +134 -0
  20. package/src/mobile/entry.mobile.ts +607 -0
  21. package/src/mobile/index.mobile.ts +3 -0
  22. package/src/mobile/mount.mobile.ts +60 -0
  23. package/src/mobile/tree.mobile.tsrx +17 -0
  24. package/src/mobile-node.mjs +126 -0
  25. package/src/mobile-regex.mjs +60 -0
  26. package/src/node.d.ts +65 -0
  27. package/src/node.mjs +536 -0
  28. package/src/session.ts +520 -0
  29. package/src/vite-mobile.d.ts +21 -0
  30. package/src/vite-mobile.mjs +101 -0
  31. package/src/vite.d.ts +11 -0
  32. package/src/vite.mjs +315 -0
  33. package/src/web/entry.web.ts +270 -0
  34. package/src/web/index.web.ts +7 -0
  35. package/src/web/mount.web.ts +86 -0
  36. package/src/web/shell.css +449 -0
  37. package/src/web/shell.web.tsrx +759 -0
  38. package/src/windows/preflight.mjs +113 -0
  39. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  40. package/types/generated/catalog.d.ts +30 -0
  41. package/types/generated/contract.d.ts +145 -0
  42. package/types/generated/controls.d.ts +28 -0
  43. package/types/generated/errors.d.ts +13 -0
  44. package/types/generated/fixture.d.ts +53 -0
  45. package/types/generated/index.d.ts +6 -0
  46. package/types/generated/macos/controller.d.ts +52 -0
  47. package/types/generated/macos/index.macos.d.ts +2 -0
  48. package/types/generated/macos/stage.macos.d.ts +10 -0
  49. package/types/generated/mobile/controller.d.ts +31 -0
  50. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  51. package/types/generated/mobile/index.mobile.d.ts +3 -0
  52. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  53. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  54. package/types/generated/session.d.ts +84 -0
  55. package/types/generated/web/entry.web.d.ts +53 -0
  56. package/types/generated/web/index.web.d.ts +7 -0
  57. package/types/generated/web/mount.web.d.ts +2 -0
  58. package/types/generated/web/shell.web.d.ts +4 -0
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'
@@ -0,0 +1,170 @@
1
+ import { WorkshopSession } from '../session'
2
+ import { applyControlInput, UNSET } from '../controls'
3
+ import type { CatalogEntry } from '../catalog'
4
+ import type { StageHost } from '../session'
5
+ import type { StoryDefinition } from '../contract'
6
+
7
+ /** Selection and draft ownership are independent of the native chrome's layout. */
8
+ export class MacOSWorkshopController {
9
+ readonly session: WorkshopSession
10
+ catalog: readonly CatalogEntry[] = []
11
+ selectedId: string | null = null
12
+ query = ''
13
+ panel: 'stories' | 'controls' | null = null
14
+ stageOnly = false
15
+ mode: 'wide' | 'compact' | 'phone' = 'wide'
16
+ drafts: Record<string, string> = {}
17
+ errors: Record<string, string> = {}
18
+ catalogError = ''
19
+ private loadGeneration = 0
20
+ private readonly listeners = new Set<() => void>()
21
+ constructor(host: StageHost, providers: readonly unknown[] = []) {
22
+ this.session = new WorkshopSession({ host, target: 'macos', globalProviders: providers })
23
+ this.session.subscribe(() => this.notify())
24
+ }
25
+ subscribe(listener: () => void) {
26
+ this.listeners.add(listener)
27
+ return () => this.listeners.delete(listener)
28
+ }
29
+ notify() {
30
+ for (const listener of this.listeners) {
31
+ listener()
32
+ }
33
+ }
34
+ resize(width: number, height: number) {
35
+ this.mode = width >= 1000 && height >= 480 ? 'wide' : width <= 560 ? 'phone' : 'compact'
36
+ this.notify()
37
+ }
38
+ async select(id: string) {
39
+ const serial = ++this.loadGeneration
40
+ this.selectedId = id
41
+ this.drafts = {}
42
+ this.errors = {}
43
+ this.panel = null
44
+ this.notify()
45
+ const entry =
46
+ this.catalog.find((e) => e.id === id && e.applicable && e.load) ??
47
+ this.catalog.find((e) => e.id === id)
48
+
49
+ if (!entry?.applicable || !entry.load) {
50
+ await this.session.dispose()
51
+ this.catalogError = entry?.reason ?? `Story "${id}" is missing; choose from the catalog.`
52
+ this.notify()
53
+ return
54
+ }
55
+
56
+ try {
57
+ const loaded = await entry.load()
58
+ if (serial !== this.loadGeneration) {
59
+ return
60
+ }
61
+
62
+ const definition = (loaded as Record<string, unknown>)[entry.exportName] as StoryDefinition<
63
+ any,
64
+ any,
65
+ any
66
+ >
67
+
68
+ if (!definition || definition.id !== id) {
69
+ throw new Error(`Story export ${entry.exportName} no longer declares ${id}`)
70
+ }
71
+
72
+ this.catalogError = ''
73
+ await this.session.select(definition)
74
+ } catch (error) {
75
+ if (serial === this.loadGeneration) {
76
+ this.session.markCompileError(error)
77
+ }
78
+ }
79
+ }
80
+ async updateCatalog(
81
+ catalog: readonly CatalogEntry[],
82
+ diagnostics: readonly { message: string }[] = [],
83
+ ) {
84
+ this.catalog = catalog
85
+ if (diagnostics.length) {
86
+ ++this.loadGeneration
87
+ this.catalogError = diagnostics.map((d) => d.message).join('\n')
88
+ this.session.markCompileError(this.catalogError)
89
+ this.notify()
90
+ return
91
+ }
92
+
93
+ this.catalogError = ''
94
+ if (this.selectedId) {
95
+ await this.select(this.selectedId)
96
+ }
97
+
98
+ this.notify()
99
+ }
100
+ control(key: string, input: unknown) {
101
+ const story = this.session.currentStory
102
+ const control = story?.controls?.[key]
103
+ if (!story || !control || this.session.state.phase !== 'ready') {
104
+ return
105
+ }
106
+
107
+ if (input !== UNSET) {
108
+ this.drafts[key] = String(input)
109
+ }
110
+
111
+ const result = applyControlInput(story, this.session.currentProps ?? {}, key, input as any)
112
+ if (result.error) {
113
+ this.errors[key] = result.error.message
114
+ } else if (result.props) {
115
+ delete this.errors[key]
116
+ this.session.applyProps(result.props)
117
+ }
118
+
119
+ this.notify()
120
+ }
121
+ async reset() {
122
+ this.drafts = {}
123
+ this.errors = {}
124
+ await this.session.reset()
125
+ this.notify()
126
+ }
127
+ async dispose() {
128
+ ++this.loadGeneration
129
+ await this.session.dispose()
130
+ this.listeners.clear()
131
+ }
132
+ /** Observable state for host automation and qualification evidence. */
133
+ inspect() {
134
+ const state = this.session.state
135
+ const generation = (
136
+ this.session as unknown as {
137
+ current?: {
138
+ fixture: unknown
139
+ store?: { getSnapshot(): unknown }
140
+ }
141
+ }
142
+ ).current
143
+
144
+ let store
145
+ try {
146
+ store = generation?.store?.getSnapshot()
147
+ } catch {
148
+ store = undefined
149
+ }
150
+
151
+ return {
152
+ phase: state.phase,
153
+ storyId: state.storyId ?? null,
154
+ selectedId: this.selectedId,
155
+ revision: state.revision,
156
+ mountedGeneration: state.mountedGeneration,
157
+ stale: state.stale,
158
+ failure: state.failure ? String(state.failure.message ?? state.failure) : null,
159
+ catalogError: this.catalogError || null,
160
+ props: this.session.currentProps ?? null,
161
+ store: store ?? null,
162
+ fixture: generation?.fixture ?? null,
163
+ mode: this.mode,
164
+ panel: this.panel,
165
+ stageOnly: this.stageOnly,
166
+ drafts: { ...this.drafts },
167
+ errors: { ...this.errors },
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,2 @@
1
+ export { createMacOSStageHost, type MacOSStageOptions } from './stage.macos'
2
+ export { MacOSWorkshopController } from './controller'