@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/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[]