@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/session.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle core — host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* States: discovered → preparing → mounting → ready, with rebuilding,
|
|
5
|
+
* failed, disposing, disconnected and restart-required. Every selection,
|
|
6
|
+
* reset, or successful source edit starts a new generation; stale async
|
|
7
|
+
* results are rejected by generation identity, and owned cleanup must
|
|
8
|
+
* finish (within the disposal budget) before the next generation mounts.
|
|
9
|
+
*/
|
|
10
|
+
import { createStore, type Store } from '@octane-xplat/ui/data'
|
|
11
|
+
import type {
|
|
12
|
+
CancellationSignal,
|
|
13
|
+
FixtureContext,
|
|
14
|
+
StoryDefinition,
|
|
15
|
+
WorkshopTarget,
|
|
16
|
+
} from './contract'
|
|
17
|
+
|
|
18
|
+
import { WorkshopError, describeCause } from './errors'
|
|
19
|
+
import {
|
|
20
|
+
Cancellation,
|
|
21
|
+
DEFAULT_DISPOSAL_TIMEOUT,
|
|
22
|
+
DEFAULT_FIXTURE_TIMEOUT,
|
|
23
|
+
OwnedResources,
|
|
24
|
+
createFixtureContext,
|
|
25
|
+
withTimeout,
|
|
26
|
+
} from './fixture'
|
|
27
|
+
|
|
28
|
+
import { freshArgs, reconcileProps } from './controls'
|
|
29
|
+
|
|
30
|
+
export type SessionPhase =
|
|
31
|
+
| 'discovered'
|
|
32
|
+
| 'preparing'
|
|
33
|
+
| 'mounting'
|
|
34
|
+
| 'ready'
|
|
35
|
+
| 'rebuilding'
|
|
36
|
+
| 'failed'
|
|
37
|
+
| 'disposing'
|
|
38
|
+
| 'disconnected'
|
|
39
|
+
| 'restart-required'
|
|
40
|
+
|
|
41
|
+
export interface SessionSnapshot {
|
|
42
|
+
phase: SessionPhase
|
|
43
|
+
/** The story currently selected (may differ from the mounted one while preparing). */
|
|
44
|
+
storyId: string | null
|
|
45
|
+
/** The generation that produced the visible stage. */
|
|
46
|
+
mountedGeneration: number
|
|
47
|
+
/** True when the visible stage is a previous successful revision. */
|
|
48
|
+
stale: boolean
|
|
49
|
+
/** Present when phase is 'failed' or 'restart-required'. */
|
|
50
|
+
failure: WorkshopError | undefined
|
|
51
|
+
/** Number of successful mounts so far. */
|
|
52
|
+
revision: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MountRequest {
|
|
56
|
+
story: StoryDefinition<any, any, any>
|
|
57
|
+
component: (props: any) => unknown
|
|
58
|
+
props: Record<string, unknown>
|
|
59
|
+
providers: readonly unknown[]
|
|
60
|
+
fixture: unknown
|
|
61
|
+
signal: CancellationSignal
|
|
62
|
+
onRenderError: (error: unknown) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MountedStory {
|
|
66
|
+
update(props: Record<string, unknown>): void
|
|
67
|
+
unmount(): void | Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Host adapter seam — the web adapter supplies createRoot mounting. */
|
|
71
|
+
export interface StageHost {
|
|
72
|
+
mount(request: MountRequest): MountedStory | Promise<MountedStory>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SessionOptions {
|
|
76
|
+
host: StageHost
|
|
77
|
+
target: WorkshopTarget
|
|
78
|
+
globalProviders?: readonly unknown[]
|
|
79
|
+
onChange?: (snapshot: SessionSnapshot) => void
|
|
80
|
+
disposalTimeout?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DisposalResult {
|
|
84
|
+
ok: boolean
|
|
85
|
+
timedOut: boolean
|
|
86
|
+
errors: unknown[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Generation {
|
|
90
|
+
id: number
|
|
91
|
+
story: StoryDefinition<any, any, any>
|
|
92
|
+
cancellation: Cancellation
|
|
93
|
+
resources: OwnedResources
|
|
94
|
+
fixture: unknown
|
|
95
|
+
store: Store<unknown> | undefined
|
|
96
|
+
mounted: MountedStory | undefined
|
|
97
|
+
props: Record<string, unknown>
|
|
98
|
+
retirement?: Promise<DisposalResult>
|
|
99
|
+
mounting?: Promise<MountedStory>
|
|
100
|
+
/** The boundary reported a render failure for this generation. */
|
|
101
|
+
failed: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class WorkshopSession {
|
|
105
|
+
private readonly options: SessionOptions
|
|
106
|
+
private generation = 0
|
|
107
|
+
private blockedFailure: WorkshopError | undefined
|
|
108
|
+
private current: Generation | undefined
|
|
109
|
+
private snapshot: SessionSnapshot = {
|
|
110
|
+
phase: 'discovered',
|
|
111
|
+
storyId: null,
|
|
112
|
+
mountedGeneration: 0,
|
|
113
|
+
stale: false,
|
|
114
|
+
failure: undefined,
|
|
115
|
+
revision: 0,
|
|
116
|
+
}
|
|
117
|
+
private readonly listeners = new Set<(snapshot: SessionSnapshot) => void>()
|
|
118
|
+
|
|
119
|
+
constructor(options: SessionOptions) {
|
|
120
|
+
this.options = options
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get state(): SessionSnapshot {
|
|
124
|
+
return this.snapshot
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get target(): WorkshopTarget {
|
|
128
|
+
return this.options.target
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The story definition backing the current generation, if any. */
|
|
132
|
+
get currentStory(): StoryDefinition<any, any, any> | undefined {
|
|
133
|
+
return this.current?.story
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The current generation's complete props bag (args + applied controls). */
|
|
137
|
+
get currentProps(): Record<string, unknown> | undefined {
|
|
138
|
+
return this.current?.props
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
subscribe(listener: (snapshot: SessionSnapshot) => void): () => void {
|
|
142
|
+
this.listeners.add(listener)
|
|
143
|
+
listener(this.snapshot)
|
|
144
|
+
return () => this.listeners.delete(listener)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private emit(patch: Partial<SessionSnapshot>): void {
|
|
148
|
+
this.snapshot = {
|
|
149
|
+
...this.snapshot,
|
|
150
|
+
...patch,
|
|
151
|
+
...(this.blockedFailure
|
|
152
|
+
? {
|
|
153
|
+
phase: 'restart-required' as const,
|
|
154
|
+
failure: this.blockedFailure,
|
|
155
|
+
stale: false,
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const listener of this.listeners) {
|
|
161
|
+
listener(this.snapshot)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.options.onChange?.(this.snapshot)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fail(error: WorkshopError, stale = false): void {
|
|
168
|
+
this.emit({ phase: 'failed', failure: error, stale })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Begin a fresh run of `story`; any prior generation is retired first. */
|
|
172
|
+
async select(
|
|
173
|
+
story: StoryDefinition<any, any, any>,
|
|
174
|
+
mode: 'select' | 'reset' | 'rebuild' = 'select',
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
if (this.blockedFailure) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id = ++this.generation
|
|
181
|
+
const gen: Generation = {
|
|
182
|
+
id,
|
|
183
|
+
story,
|
|
184
|
+
cancellation: new Cancellation(),
|
|
185
|
+
resources: new OwnedResources((error) => {
|
|
186
|
+
this.blockedFailure = new WorkshopError(
|
|
187
|
+
'cleanup',
|
|
188
|
+
`Late resource cleanup failed — restart the workshop target: ${describeCause(error)}`,
|
|
189
|
+
{ storyId: story.id },
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
this.emit({ phase: 'restart-required' })
|
|
193
|
+
}, this.options.disposalTimeout ?? DEFAULT_DISPOSAL_TIMEOUT),
|
|
194
|
+
fixture: undefined,
|
|
195
|
+
store: undefined,
|
|
196
|
+
mounted: undefined,
|
|
197
|
+
props: {},
|
|
198
|
+
failed: false,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const previous = this.current
|
|
202
|
+
if (previous !== undefined) {
|
|
203
|
+
this.emit({ phase: 'disposing', storyId: story.id })
|
|
204
|
+
const disposal = await this.retire(previous)
|
|
205
|
+
if (id !== this.generation) {
|
|
206
|
+
return // a newer selection superseded this one mid-disposal
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!disposal.ok) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.current = gen
|
|
215
|
+
this.emit({
|
|
216
|
+
phase: mode === 'rebuild' ? 'rebuilding' : 'preparing',
|
|
217
|
+
storyId: story.id,
|
|
218
|
+
stale: false,
|
|
219
|
+
failure: undefined,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (typeof story.state === 'function') {
|
|
224
|
+
gen.store = createStore(story.state()) as Store<unknown>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof story.fixture === 'function') {
|
|
228
|
+
const timeout = story.fixtureTimeout ?? DEFAULT_FIXTURE_TIMEOUT
|
|
229
|
+
const context: FixtureContext = createFixtureContext(
|
|
230
|
+
this.options.target,
|
|
231
|
+
story.capabilities ?? [],
|
|
232
|
+
gen.cancellation,
|
|
233
|
+
gen.resources,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
let produced: Promise<unknown>
|
|
237
|
+
try {
|
|
238
|
+
produced = Promise.resolve(story.fixture(context))
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new WorkshopError('fixture', `fixture threw: ${describeCause(error)}`, {
|
|
241
|
+
storyId: story.id,
|
|
242
|
+
cause: error,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
gen.fixture = await withTimeout(
|
|
247
|
+
Promise.race([produced, gen.cancellation.promise]),
|
|
248
|
+
timeout,
|
|
249
|
+
() => gen.cancellation.cancel(),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// Preparation failure: unwind everything registered so far.
|
|
254
|
+
const disposal = await this.retire(gen)
|
|
255
|
+
if (!disposal.ok) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (id !== this.generation) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const wrapped =
|
|
264
|
+
error instanceof WorkshopError
|
|
265
|
+
? error
|
|
266
|
+
: new WorkshopError('fixture', `fixture rejected or timed out: ${describeCause(error)}`, {
|
|
267
|
+
storyId: story.id,
|
|
268
|
+
cause: error,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
this.fail(wrapped)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (id !== this.generation || this.blockedFailure) {
|
|
276
|
+
// Late success on a cancelled generation: its owned resources are
|
|
277
|
+
// still released exactly once.
|
|
278
|
+
await this.retire(gen)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.emit({ phase: 'mounting', storyId: story.id })
|
|
283
|
+
gen.props = freshArgs(story.args ?? {})
|
|
284
|
+
try {
|
|
285
|
+
gen.mounting = Promise.resolve(
|
|
286
|
+
this.options.host.mount({
|
|
287
|
+
story,
|
|
288
|
+
component: story.component,
|
|
289
|
+
props: this.injectedProps(gen, gen.props),
|
|
290
|
+
providers: [...(this.options.globalProviders ?? []), ...(story.providers ?? [])],
|
|
291
|
+
fixture: gen.fixture,
|
|
292
|
+
signal: gen.cancellation,
|
|
293
|
+
onRenderError: (error) => {
|
|
294
|
+
if (gen.id === this.generation) {
|
|
295
|
+
// Awaited mounts yield to microtasks — mark so the 'ready'
|
|
296
|
+
// emit below does not overwrite this failure.
|
|
297
|
+
gen.failed = true
|
|
298
|
+
this.fail(
|
|
299
|
+
new WorkshopError('mount', `render failed: ${describeCause(error)}`, {
|
|
300
|
+
storyId: story.id,
|
|
301
|
+
cause: error,
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
void this.retire(gen)
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
gen.mounted = await gen.mounting
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const disposal = await this.retire(gen)
|
|
314
|
+
if (!disposal.ok) {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (id !== this.generation) {
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.fail(
|
|
323
|
+
new WorkshopError('mount', `mount failed: ${describeCause(error)}`, {
|
|
324
|
+
storyId: story.id,
|
|
325
|
+
cause: error,
|
|
326
|
+
}),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (id !== this.generation) {
|
|
333
|
+
await this.retire(gen)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (gen.failed) {
|
|
338
|
+
// The boundary already reported the render failure for this mount.
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.emit({
|
|
343
|
+
phase: 'ready',
|
|
344
|
+
storyId: story.id,
|
|
345
|
+
mountedGeneration: id,
|
|
346
|
+
stale: false,
|
|
347
|
+
failure: undefined,
|
|
348
|
+
revision: this.snapshot.revision + 1,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private injectedProps(gen: Generation, props: Record<string, unknown>): Record<string, unknown> {
|
|
353
|
+
const merged = { ...props }
|
|
354
|
+
if (gen.store !== undefined) {
|
|
355
|
+
merged.state = gen.store
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (typeof gen.story.fixture === 'function') {
|
|
359
|
+
merged.fixture = gen.fixture
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return merged
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Apply the complete next props bag — controls preserve the current
|
|
367
|
+
* fixture, providers, store and component-local state. Removed keys are
|
|
368
|
+
* forwarded as explicit `undefined`.
|
|
369
|
+
*/
|
|
370
|
+
applyProps(next: Record<string, unknown>): void {
|
|
371
|
+
const gen = this.current
|
|
372
|
+
if (!gen?.mounted || this.snapshot.phase !== 'ready') {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const reconciled = reconcileProps(gen.props, next)
|
|
377
|
+
gen.props = next
|
|
378
|
+
gen.mounted.update(this.injectedProps(gen, reconciled))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A full fresh run: fresh args, fresh store, fresh fixture, fresh root. */
|
|
382
|
+
async reset(): Promise<void> {
|
|
383
|
+
const story = this.current?.story
|
|
384
|
+
if (!story) {
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await this.select(story, 'reset')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A successful source edit remounts the selected story revision. */
|
|
392
|
+
async reload(next: StoryDefinition<any, any, any>): Promise<void> {
|
|
393
|
+
await this.select(next, 'rebuild')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A source edit is in flight; the next success remounts. */
|
|
397
|
+
markRebuilding(): void {
|
|
398
|
+
if (this.snapshot.phase === 'ready') {
|
|
399
|
+
this.emit({ phase: 'rebuilding', stale: true })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A failed source edit keeps the last successful stage, visibly stale. */
|
|
404
|
+
markCompileError(error: unknown): void {
|
|
405
|
+
this.emit({
|
|
406
|
+
phase: 'failed',
|
|
407
|
+
stale: this.snapshot.mountedGeneration > 0,
|
|
408
|
+
failure: new WorkshopError('compile', `source update failed: ${describeCause(error)}`, {
|
|
409
|
+
storyId: this.snapshot.storyId ?? undefined,
|
|
410
|
+
cause: error,
|
|
411
|
+
}),
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
markDisconnected(): void {
|
|
416
|
+
this.emit({ phase: 'disconnected' })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Back to live after a disconnect notice. */
|
|
420
|
+
markConnected(): void {
|
|
421
|
+
if (this.snapshot.phase === 'disconnected') {
|
|
422
|
+
this.emit({ phase: this.current?.mounted ? 'ready' : 'discovered' })
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Retire a generation: cancel, unmount, run owned cleanups (all of them). */
|
|
427
|
+
private retire(gen: Generation): Promise<DisposalResult> {
|
|
428
|
+
if (gen.retirement) {
|
|
429
|
+
return gen.retirement
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Cache one retirement promise so concurrent selections await the same
|
|
433
|
+
// cleanup rather than treating an in-flight retirement as completed.
|
|
434
|
+
gen.retirement = Promise.resolve().then(() => this.disposeGeneration(gen))
|
|
435
|
+
return gen.retirement
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async disposeGeneration(gen: Generation): Promise<DisposalResult> {
|
|
439
|
+
gen.cancellation.cancel()
|
|
440
|
+
const unmount = (async (): Promise<unknown[]> => {
|
|
441
|
+
// An asynchronous host may acquire its root after cancellation.
|
|
442
|
+
// Keep that acquisition inside the disposal budget and own its root.
|
|
443
|
+
let mounted = gen.mounted
|
|
444
|
+
if (!mounted && gen.mounting) {
|
|
445
|
+
try {
|
|
446
|
+
mounted = await gen.mounting
|
|
447
|
+
} catch {
|
|
448
|
+
/* mount failed */
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (mounted) {
|
|
453
|
+
try {
|
|
454
|
+
await mounted.unmount()
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return [error]
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return []
|
|
461
|
+
})()
|
|
462
|
+
|
|
463
|
+
// Start all owned disposal even when a root unmount is asynchronous or
|
|
464
|
+
// hung. The next generation still awaits the combined acknowledgement.
|
|
465
|
+
const work = Promise.all([unmount, gen.resources.dispose()]).then(([errors, resources]) => ({
|
|
466
|
+
errors: [...errors, ...resources.errors],
|
|
467
|
+
}))
|
|
468
|
+
|
|
469
|
+
let timedOut = false
|
|
470
|
+
const result = await withTimeout(
|
|
471
|
+
work,
|
|
472
|
+
this.options.disposalTimeout ?? DEFAULT_DISPOSAL_TIMEOUT,
|
|
473
|
+
() => {
|
|
474
|
+
timedOut = true
|
|
475
|
+
},
|
|
476
|
+
).catch((error) => ({ errors: [error] }))
|
|
477
|
+
|
|
478
|
+
const disposal = {
|
|
479
|
+
ok: !timedOut && result.errors.length === 0,
|
|
480
|
+
timedOut,
|
|
481
|
+
errors: result.errors,
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!disposal.ok) {
|
|
485
|
+
this.blockedFailure = new WorkshopError(
|
|
486
|
+
'cleanup',
|
|
487
|
+
timedOut
|
|
488
|
+
? 'Owned cleanup timed out — restart the workshop target'
|
|
489
|
+
: `Owned cleanup failed — restart the workshop target: ${result.errors.map(describeCause).join('; ')}`,
|
|
490
|
+
{ storyId: gen.story.id },
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
this.emit({ phase: 'restart-required' })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return disposal
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Dispose the session entirely (server shutdown / target restart). */
|
|
500
|
+
async dispose(): Promise<void> {
|
|
501
|
+
const id = ++this.generation
|
|
502
|
+
const previous = this.current
|
|
503
|
+
if (previous) {
|
|
504
|
+
await this.retire(previous)
|
|
505
|
+
if (this.current === previous) {
|
|
506
|
+
this.current = undefined
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (id === this.generation && !this.blockedFailure) {
|
|
511
|
+
this.emit({
|
|
512
|
+
phase: 'discovered',
|
|
513
|
+
storyId: null,
|
|
514
|
+
mountedGeneration: 0,
|
|
515
|
+
stale: false,
|
|
516
|
+
failure: undefined,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { UserConfig } from 'vite'
|
|
2
|
+
import type { WorkshopConfig } from '../types/generated/contract.js'
|
|
3
|
+
/** Preserves the consumer config and prepends the native workshop's pre HMR hook. */
|
|
4
|
+
export declare function withWorkshopMobile(
|
|
5
|
+
config: UserConfig,
|
|
6
|
+
options?: {
|
|
7
|
+
cwd?: string
|
|
8
|
+
target?: 'ios' | 'android'
|
|
9
|
+
story?: string
|
|
10
|
+
config?: WorkshopConfig
|
|
11
|
+
},
|
|
12
|
+
): UserConfig
|
|
13
|
+
|
|
14
|
+
/** @internal Virtual module identity; exposed for adapter contract checks. */
|
|
15
|
+
export declare const MOBILE_CATALOG_ID: string
|
|
16
|
+
/** @internal Generate a statically filtered native catalog without module evaluation. */
|
|
17
|
+
export declare function mobileCatalogCode(
|
|
18
|
+
root: string,
|
|
19
|
+
config: WorkshopConfig,
|
|
20
|
+
target: 'ios' | 'android',
|
|
21
|
+
): string
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { mobileRegexPlugin } from './mobile-regex.mjs'
|
|
4
|
+
import { discoverStories } from './discover.mjs'
|
|
5
|
+
|
|
6
|
+
export const MOBILE_CATALOG_ID = '\0xplat-workshop-mobile-catalog'
|
|
7
|
+
const fsUrl = (file) => `/@fs/${file.replaceAll('\\', '/')}`
|
|
8
|
+
|
|
9
|
+
export function mobileCatalogCode(root, config, target) {
|
|
10
|
+
const { entries, diagnostics } = discoverStories(root, { ...config.stories, target })
|
|
11
|
+
const loaders = entries
|
|
12
|
+
.filter((e) => e.applicable && !diagnostics.length)
|
|
13
|
+
.map(
|
|
14
|
+
(e) =>
|
|
15
|
+
`${JSON.stringify(e.id)}: (stamp) => stamp === undefined ? import(${JSON.stringify(fsUrl(e.file))}) : import(/* @vite-ignore */ ${JSON.stringify(fsUrl(e.file))} + '?t=' + encodeURIComponent(stamp))`,
|
|
16
|
+
)
|
|
17
|
+
.join(',\n')
|
|
18
|
+
|
|
19
|
+
return `import { catalogDidUpdate } from '@octane-xplat/workshop/mobile'
|
|
20
|
+
export const diagnostics = ${JSON.stringify(diagnostics)}
|
|
21
|
+
const loaders = {${loaders}}
|
|
22
|
+
export const catalog = ${JSON.stringify(entries)}.map(entry => loaders[entry.id] ? {...entry, load: loaders[entry.id]} : entry)
|
|
23
|
+
if (import.meta.hot) import.meta.hot.accept(next => catalogDidUpdate(next?.catalog ?? catalog, next?.diagnostics ?? diagnostics))
|
|
24
|
+
`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Apply AFTER resolving the consumer preset, so this pre hook precedes octane(). */
|
|
28
|
+
export function withWorkshopMobile(config, options = {}) {
|
|
29
|
+
const target = options.target ?? process.env.XPLAT_WORKSHOP_TARGET
|
|
30
|
+
if (!target) {
|
|
31
|
+
return config
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!['ios', 'android'].includes(target)) {
|
|
35
|
+
throw new Error('Workshop mobile target must be ios or android')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const root = options.cwd ?? process.cwd()
|
|
39
|
+
const workshop = options.config ?? JSON.parse(process.env.XPLAT_WORKSHOP_CONFIG ?? '{}')
|
|
40
|
+
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
|
|
41
|
+
const main = resolve(root, manifest.main ?? 'src/index.ts')
|
|
42
|
+
const plugin = {
|
|
43
|
+
name: 'xplat-workshop-mobile',
|
|
44
|
+
enforce: 'pre',
|
|
45
|
+
resolveId(id) {
|
|
46
|
+
if (id === MOBILE_CATALOG_ID) {
|
|
47
|
+
return id
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
load(id) {
|
|
51
|
+
if (id === MOBILE_CATALOG_ID) {
|
|
52
|
+
return mobileCatalogCode(root, workshop, target)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Retain the NativeScript polyfills/dev-session bootstrap; replace
|
|
56
|
+
// only its consumer entry, without touching the production file.
|
|
57
|
+
if (id.split('?')[0] !== main) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const presets = workshop.providerModules ?? []
|
|
62
|
+
return `import { startWorkshop } from '@octane-xplat/workshop/mobile'
|
|
63
|
+
import { catalog, diagnostics } from ${JSON.stringify(MOBILE_CATALOG_ID)}
|
|
64
|
+
import '@octane-xplat/ui/theme/tokens.css'
|
|
65
|
+
${presets.map((p, i) => `import * as preset${i} from ${JSON.stringify(fsUrl(resolve(root, p)))}`).join('\n')}
|
|
66
|
+
const workshopRuntime = startWorkshop({catalog, diagnostics, target: ${JSON.stringify(target)}, initialStoryId: ${JSON.stringify(options.story ?? process.env.XPLAT_WORKSHOP_STORY ?? null)}, globalProviders: [${presets.map((_, i) => `...(preset${i}.providers ?? preset${i}.default ?? [])`).join(',')}]})
|
|
67
|
+
if (import.meta.hot) {
|
|
68
|
+
import.meta.hot.accept()
|
|
69
|
+
import.meta.hot.on('vite:error', error => workshopRuntime.controller.session.markCompileError(error.err?.message ?? error))
|
|
70
|
+
import.meta.hot.on('vite:ws:disconnect', () => workshopRuntime.controller.session.markDisconnected())
|
|
71
|
+
import.meta.hot.on('vite:ws:connect', () => workshopRuntime.controller.session.markConnected())
|
|
72
|
+
import.meta.hot.dispose(() => { void workshopRuntime.dispose().catch(error => console.error('Workshop cleanup requires restart:', error)) })
|
|
73
|
+
}
|
|
74
|
+
`
|
|
75
|
+
},
|
|
76
|
+
hotUpdate: {
|
|
77
|
+
order: 'pre',
|
|
78
|
+
handler(ctx) {
|
|
79
|
+
// NativeScript's active Vite environment is used directly; no
|
|
80
|
+
// browser-only client assumption. All consumer source edits
|
|
81
|
+
// refresh the selected revision, including dependency edits.
|
|
82
|
+
if (
|
|
83
|
+
!/\.(?:tsrx|tsx|ts|jsx|js|mjs)$/.test(ctx.file) ||
|
|
84
|
+
ctx.file.includes('/node_modules/')
|
|
85
|
+
) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const catalog = this.environment.moduleGraph.getModuleById(MOBILE_CATALOG_ID)
|
|
90
|
+
if (!catalog) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.environment.moduleGraph.invalidateModule(catalog)
|
|
95
|
+
return [catalog]
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { ...config, plugins: [plugin, mobileRegexPlugin(), ...(config.plugins ?? [])] }
|
|
101
|
+
}
|
package/src/vite.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
import type { WorkshopConfig } from '../types/generated/contract.js'
|
|
3
|
+
|
|
4
|
+
export interface XplatWorkshopOptions {
|
|
5
|
+
cwd?: string
|
|
6
|
+
config?: WorkshopConfig
|
|
7
|
+
target?: 'web' | 'ios' | 'android' | 'macos' | 'windows' | 'linux'
|
|
8
|
+
story?: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function xplatWorkshop(options?: XplatWorkshopOptions): Plugin[]
|