@octane-xplat/workshop 0.0.0-nightly.g2e22171
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/node/index.js +50 -0
- package/package.json +127 -0
- package/src/catalog.ts +58 -0
- package/src/contract.ts +257 -0
- package/src/controls.ts +159 -0
- package/src/discover.mjs +584 -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/shell.macos.mjs +673 -0
- package/src/macos/stage.macos.ts +182 -0
- package/src/macos-node.mjs +95 -0
- package/src/macos-vite.mjs +47 -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/mobile-node.mjs +126 -0
- package/src/mobile-regex.mjs +60 -0
- package/src/node.d.ts +65 -0
- package/src/node.mjs +536 -0
- package/src/session.ts +520 -0
- package/src/vite-mobile.d.ts +21 -0
- package/src/vite-mobile.mjs +101 -0
- package/src/vite.d.ts +11 -0
- package/src/vite.mjs +315 -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/src/windows/preflight.mjs +113 -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/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,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
|
+
}
|