@use-voltra/expo-plugin 2.2.0 → 2.3.1

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 (57) hide show
  1. package/build/cjs/constants.js +1 -3
  2. package/build/cjs/constants.js.map +1 -1
  3. package/build/cjs/dynamic-live-activity.js +6 -0
  4. package/build/cjs/dynamic-live-activity.js.map +1 -0
  5. package/build/cjs/index.js +15 -2
  6. package/build/cjs/index.js.map +1 -1
  7. package/build/cjs/serverUpdate.js +119 -0
  8. package/build/cjs/serverUpdate.js.map +1 -0
  9. package/build/cjs/utils/packageVersion.js +49 -0
  10. package/build/cjs/utils/packageVersion.js.map +1 -0
  11. package/build/cjs/utils/prerender.js +25 -159
  12. package/build/cjs/utils/prerender.js.map +1 -1
  13. package/build/cjs/widgetServerUpdate.js +63 -0
  14. package/build/cjs/widgetServerUpdate.js.map +1 -0
  15. package/build/esm/constants.js +0 -2
  16. package/build/esm/constants.js.map +1 -1
  17. package/build/esm/dynamic-live-activity.js +2 -0
  18. package/build/esm/dynamic-live-activity.js.map +1 -0
  19. package/build/esm/index.js +6 -2
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/serverUpdate.js +112 -0
  22. package/build/esm/serverUpdate.js.map +1 -0
  23. package/build/esm/utils/packageVersion.js +13 -0
  24. package/build/esm/utils/packageVersion.js.map +1 -0
  25. package/build/esm/utils/prerender.js +24 -126
  26. package/build/esm/utils/prerender.js.map +1 -1
  27. package/build/esm/widgetServerUpdate.js +59 -0
  28. package/build/esm/widgetServerUpdate.js.map +1 -0
  29. package/build/types/constants.d.ts +0 -2
  30. package/build/types/constants.d.ts.map +1 -1
  31. package/build/types/dynamic-live-activity.d.ts +2 -0
  32. package/build/types/dynamic-live-activity.d.ts.map +1 -0
  33. package/build/types/index.d.ts +11 -4
  34. package/build/types/index.d.ts.map +1 -1
  35. package/build/types/serverUpdate.d.ts +80 -0
  36. package/build/types/serverUpdate.d.ts.map +1 -0
  37. package/build/types/types.d.ts +22 -0
  38. package/build/types/types.d.ts.map +1 -1
  39. package/build/types/utils/packageVersion.d.ts +2 -0
  40. package/build/types/utils/packageVersion.d.ts.map +1 -0
  41. package/build/types/utils/prerender.d.ts +22 -8
  42. package/build/types/utils/prerender.d.ts.map +1 -1
  43. package/build/types/widgetServerUpdate.d.ts +34 -0
  44. package/build/types/widgetServerUpdate.d.ts.map +1 -0
  45. package/package.json +3 -3
  46. package/src/constants.ts +0 -3
  47. package/src/dynamic-live-activity.node.test.ts +18 -0
  48. package/src/dynamic-live-activity.ts +1 -0
  49. package/src/index.ts +33 -3
  50. package/src/serverUpdate.node.test.ts +126 -0
  51. package/src/serverUpdate.ts +160 -0
  52. package/src/types.ts +25 -0
  53. package/src/utils/packageVersion.node.test.ts +24 -0
  54. package/src/utils/packageVersion.ts +19 -0
  55. package/src/utils/prerender.node.test.ts +2 -1
  56. package/src/utils/prerender.ts +34 -155
  57. package/src/widgetServerUpdate.ts +109 -0
@@ -1,3 +1,4 @@
1
+ import { type WidgetModuleLoader, type WidgetModulePlatform } from '@use-voltra/compiler';
1
2
  import type { WidgetInitialStatePath } from '../types';
2
3
  /**
3
4
  * Type for the widget renderer function
@@ -12,20 +13,32 @@ export interface PrerenderableWidget {
12
13
  }
13
14
  /** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */
14
15
  export type PrerenderedWidgetStates = Map<string, Map<string, string>>;
16
+ export interface WidgetModuleEvaluationOptions {
17
+ projectRoot: string;
18
+ /** Platform being prebuilt. Determines `Platform.OS` inside widget code. */
19
+ platform: WidgetModulePlatform;
20
+ /** Reuse a loader across several widgets so warnings are reported once per prebuild. */
21
+ loader?: WidgetModuleLoader;
22
+ }
23
+ /**
24
+ * Create the loader used to evaluate widget source during prebuild.
25
+ *
26
+ * Evaluation rules live in `@use-voltra/compiler` so that prebuild, `voltra apply`, and
27
+ * the Metro widget bundler agree on what widget code may import.
28
+ */
29
+ export declare function createPrerenderWidgetModuleLoader(projectRoot: string, platform: WidgetModulePlatform): WidgetModuleLoader;
15
30
  /**
16
- * Evaluate a widget module using Babel transpilation and Node.js VM.
17
- * This allows executing widget code that uses JSX and React components.
18
- * Local module dependencies are also transpiled with the same Babel settings.
31
+ * Evaluate a widget module and return its exports object.
19
32
  *
20
33
  * Exported so platform-specific prerender flows can reuse the same module loader rather
21
- * than duplicating the Babel + VM scaffolding. The returned value is the module's exports
22
- * object — callers decide whether to read `.default`, a named export, etc.
34
+ * than duplicating the Babel + VM scaffolding. Callers decide whether to read `.default`,
35
+ * a named export, etc.
23
36
  */
24
- export declare function evaluateWidgetModuleExports(projectRoot: string, filePath: string, warnedRedirects?: Set<string>): any;
37
+ export declare function evaluateWidgetModuleExports(filePath: string, options: WidgetModuleEvaluationOptions): any;
25
38
  /**
26
39
  * Evaluate a widget file as a server-style WidgetVariants module and return its object export.
27
40
  */
28
- export declare function evaluateWidgetModule(projectRoot: string, filePath: string, warnedRedirects?: Set<string>): any;
41
+ export declare function evaluateWidgetModule(filePath: string, options: WidgetModuleEvaluationOptions): any;
29
42
  /**
30
43
  * Prerender widget initial states for build-time inclusion.
31
44
  *
@@ -36,7 +49,8 @@ export declare function evaluateWidgetModule(projectRoot: string, filePath: stri
36
49
  * @param widgets - Array of widget configurations
37
50
  * @param projectRoot - Root directory of the Expo project
38
51
  * @param renderer - The renderer function to use (voltra/server or voltra/android/server)
52
+ * @param platform - Platform being prebuilt
39
53
  * @returns Map of widgetId -> (locale key -> prerendered JSON string)
40
54
  */
41
- export declare function prerenderWidgetState(widgets: PrerenderableWidget[], projectRoot: string, renderer: WidgetRenderer): Promise<PrerenderedWidgetStates>;
55
+ export declare function prerenderWidgetState(widgets: PrerenderableWidget[], projectRoot: string, renderer: WidgetRenderer, platform: WidgetModulePlatform): Promise<PrerenderedWidgetStates>;
42
56
  //# sourceMappingURL=prerender.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["../../../src/utils/prerender.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,sBAAsB,EAAe,MAAM,UAAU,CAAA;AAInE;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,MAAM,CAAA;AAEtD;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,gBAAgB,CAAC,EAAE,sBAAsB,CAAA;CAC1C;AAED,8FAA8F;AAC9F,MAAM,MAAM,uBAAuB,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;AA2EtE;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,eAAe,cAAoB,GAClC,GAAG,CAsEL;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,eAAe,cAAoB,GAAG,GAAG,CASpH;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,mBAAmB,EAAE,EAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,cAAc,GACvB,OAAO,CAAC,uBAAuB,CAAC,CAgClC"}
1
+ {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["../../../src/utils/prerender.ts"],"names":[],"mappings":"AAEA,OAAO,EAA4B,KAAK,kBAAkB,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAEnH,OAAO,KAAK,EAAE,sBAAsB,EAAe,MAAM,UAAU,CAAA;AAInE;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,MAAM,CAAA;AAEtD;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,gBAAgB,CAAC,EAAE,sBAAsB,CAAA;CAC1C;AAED,8FAA8F;AAC9F,MAAM,MAAM,uBAAuB,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;AAEtE,MAAM,WAAW,6BAA6B;IAC5C,WAAW,EAAE,MAAM,CAAA;IACnB,4EAA4E;IAC5E,QAAQ,EAAE,oBAAoB,CAAA;IAC9B,wFAAwF;IACxF,MAAM,CAAC,EAAE,kBAAkB,CAAA;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAC/C,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,oBAAoB,GAC7B,kBAAkB,CAMpB;AAMD;;;;;;GAMG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,6BAA6B,GAAG,GAAG,CAEzG;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,6BAA6B,GAAG,GAAG,CAQlG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,mBAAmB,EAAE,EAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,uBAAuB,CAAC,CAgClC"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Expo-plugin side of the `serverUpdate` config rules. The rules themselves live in
3
+ * `./serverUpdate`, which the `voltra` CLI mirrors; this module only turns them into the plugin's
4
+ * thrown errors and console warnings.
5
+ */
6
+ /** `serverUpdate` as it appears in app.json, before defaults are applied. */
7
+ export interface WidgetServerUpdateConfig {
8
+ url?: string;
9
+ intervalMinutes?: number;
10
+ refresh?: boolean;
11
+ }
12
+ /** `serverUpdate` after defaults, as the generators consume it. */
13
+ export interface ResolvedWidgetServerUpdateConfig {
14
+ /** Absent when app.json set no URL — the app supplies one at runtime. */
15
+ url?: string;
16
+ intervalMinutes: number;
17
+ refresh: boolean;
18
+ }
19
+ export interface WidgetServerUpdateRules {
20
+ /** True when the widget has an `entry` and so renders bundled JS from fetched props. */
21
+ hasEntry: boolean;
22
+ /** Interval used when the widget sets none. Ignored for widgets with `entry`. */
23
+ defaultIntervalMinutes: number;
24
+ /** Platform floor for payload widgets. Ignored for widgets with `entry`. */
25
+ minimumIntervalMinutes: number;
26
+ }
27
+ export declare function validateWidgetServerUpdate(serverUpdate: unknown, widgetId: string, rules: WidgetServerUpdateRules): void;
28
+ /**
29
+ * Applies defaults to a validated `serverUpdate`. Generators call this instead of reading
30
+ * `intervalMinutes` directly, so the value written into a plist or a generated asset is the one
31
+ * the widget will actually be scheduled on.
32
+ */
33
+ export declare function resolveWidgetServerUpdate(serverUpdate: WidgetServerUpdateConfig, rules: WidgetServerUpdateRules): ResolvedWidgetServerUpdateConfig;
34
+ //# sourceMappingURL=widgetServerUpdate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"widgetServerUpdate.d.ts","sourceRoot":"","sources":["../../src/widgetServerUpdate.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,6EAA6E;AAC7E,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,mEAAmE;AACnE,MAAM,WAAW,gCAAgC;IAC/C,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,eAAe,EAAE,MAAM,CAAA;IACvB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,wFAAwF;IACxF,QAAQ,EAAE,OAAO,CAAA;IACjB,iFAAiF;IACjF,sBAAsB,EAAE,MAAM,CAAA;IAC9B,4EAA4E;IAC5E,sBAAsB,EAAE,MAAM,CAAA;CAC/B;AAED,wBAAgB,0BAA0B,CACxC,YAAY,EAAE,OAAO,EACrB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,uBAAuB,GAC7B,IAAI,CA4CN;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,wBAAwB,EACtC,KAAK,EAAE,uBAAuB,GAC7B,gCAAgC,CAiBlC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-voltra/expo-plugin",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Shared utilities for Voltra Expo config plugins",
5
5
  "main": "build/cjs/index.js",
6
6
  "module": "build/esm/index.js",
@@ -20,7 +20,8 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@babel/core": "^7.27.4"
23
+ "@use-voltra/compiler": "^2.3.1",
24
+ "@use-voltra/core": "^2.3.1"
24
25
  },
25
26
  "keywords": [
26
27
  "voltra",
@@ -44,7 +45,6 @@
44
45
  "react-native": "*"
45
46
  },
46
47
  "devDependencies": {
47
- "@types/babel__core": "^7.20.5",
48
48
  "@types/jest": "^29.5.14",
49
49
  "@types/node": "^20.19.25",
50
50
  "jest": "^29.7.0",
package/src/constants.ts CHANGED
@@ -1,5 +1,2 @@
1
- /** Extensions to try when resolving module paths for pre-rendering */
2
- export const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '']
3
-
4
1
  /** Maximum image size in bytes for widget / Live Activity assets (4KB limit) */
5
2
  export const MAX_IMAGE_SIZE_BYTES = 4096
@@ -0,0 +1,18 @@
1
+ import { getDynamicLiveActivityAttributesType as getIOSAttributesType } from '../../ios/src/live-activity/dynamic'
2
+ import { getDynamicLiveActivityAttributesType as getCanonicalAttributesType } from '../../core/src/dynamic-live-activity'
3
+
4
+ import { getDynamicLiveActivityAttributesType as getExpoPluginAttributesType } from './dynamic-live-activity'
5
+
6
+ describe('Dynamic Live Activity attributes type naming', () => {
7
+ it.each([
8
+ ['', 'VoltraLiveActivityAttributes'],
9
+ ['order_finished', 'VoltraOrderFinishedLiveActivityAttributes'],
10
+ ['order__finished', 'VoltraOrderFinishedLiveActivityAttributes'],
11
+ ['_driver_arrived_', 'VoltraDriverArrivedLiveActivityAttributes'],
12
+ ['alreadyCamel', 'VoltraAlreadyCamelLiveActivityAttributes'],
13
+ ])('keeps core, Expo generation, and the iOS public helper aligned for %p', (definitionId, expected) => {
14
+ expect(getCanonicalAttributesType(definitionId)).toBe(expected)
15
+ expect(getExpoPluginAttributesType(definitionId)).toBe(expected)
16
+ expect(getIOSAttributesType(definitionId)).toBe(expected)
17
+ })
18
+ })
@@ -0,0 +1 @@
1
+ export { getDynamicLiveActivityAttributesType } from '@use-voltra/core/dynamic-live-activity'
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
- export { MAX_IMAGE_SIZE_BYTES, MODULE_EXTENSIONS } from './constants'
1
+ export { MAX_IMAGE_SIZE_BYTES } from './constants'
2
+ export { getDynamicLiveActivityAttributesType } from './dynamic-live-activity'
2
3
  export type {
3
4
  DynamicWidgetEntryConfig,
5
+ DynamicLiveActivityEntryConfig,
6
+ DynamicLiveActivityManifest,
7
+ DynamicLiveActivityManifestDefinition,
4
8
  DynamicWidgetManifest,
5
9
  DynamicWidgetManifestWidget,
6
10
  DynamicWidgetPlatform,
@@ -8,6 +12,20 @@ export type {
8
12
  WidgetLabel,
9
13
  WidgetLocalizedCopy,
10
14
  } from './types'
15
+ export type { ServerUpdateIntervalResolution, ServerUpdateUrlResolution } from './serverUpdate'
16
+ export {
17
+ DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
18
+ isLocalHttpHost,
19
+ resolveServerUpdateInterval,
20
+ resolveServerUpdateUrl,
21
+ validateServerUpdateRefresh,
22
+ } from './serverUpdate'
23
+ export type {
24
+ ResolvedWidgetServerUpdateConfig,
25
+ WidgetServerUpdateConfig,
26
+ WidgetServerUpdateRules,
27
+ } from './widgetServerUpdate'
28
+ export { resolveWidgetServerUpdate, validateWidgetServerUpdate } from './widgetServerUpdate'
11
29
  export {
12
30
  assertValidLocaleKey,
13
31
  normalizeWidgetEntryPath,
@@ -20,6 +38,18 @@ export { addApplicationGroupsEntitlement } from './utils/entitlements'
20
38
  export { resolveFontPaths } from './utils/fonts'
21
39
  export { normalizeLocaleTag, pickLocalizedValue } from './utils/localePick'
22
40
  export { logger } from './utils/logger'
23
- export type { PrerenderableWidget, PrerenderedWidgetStates, WidgetRenderer } from './utils/prerender'
24
- export { evaluateWidgetModule, evaluateWidgetModuleExports, prerenderWidgetState } from './utils/prerender'
41
+ export { resolveInstalledPackageVersion } from './utils/packageVersion'
42
+ export type {
43
+ PrerenderableWidget,
44
+ PrerenderedWidgetStates,
45
+ WidgetModuleEvaluationOptions,
46
+ WidgetRenderer,
47
+ } from './utils/prerender'
48
+ export {
49
+ createPrerenderWidgetModuleLoader,
50
+ evaluateWidgetModule,
51
+ evaluateWidgetModuleExports,
52
+ prerenderWidgetState,
53
+ } from './utils/prerender'
54
+ export type { WidgetModuleLoader, WidgetModulePlatform } from '@use-voltra/compiler'
25
55
  export { isWidgetLocalizedMap, widgetLabelEnglish } from './utils/widgetLabel'
@@ -0,0 +1,126 @@
1
+ import {
2
+ DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
3
+ resolveServerUpdateInterval,
4
+ resolveServerUpdateUrl,
5
+ validateServerUpdateRefresh,
6
+ } from './serverUpdate'
7
+
8
+ const PAYLOAD_WIDGET = {
9
+ context: 'android.widgets[portfolio].serverUpdate',
10
+ hasEntry: false,
11
+ defaultIntervalMinutes: 60,
12
+ minimumIntervalMinutes: 15,
13
+ }
14
+
15
+ const DYNAMIC_WIDGET = { ...PAYLOAD_WIDGET, hasEntry: true }
16
+
17
+ describe('resolveServerUpdateInterval', () => {
18
+ it('falls back to the platform default for a payload widget that sets no interval', () => {
19
+ expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: undefined })).toEqual({
20
+ kind: 'ok',
21
+ intervalMinutes: 60,
22
+ })
23
+ })
24
+
25
+ it('falls back to 15 for a widget with an entry, ignoring the platform default', () => {
26
+ expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: undefined })).toEqual({
27
+ kind: 'ok',
28
+ intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
29
+ })
30
+ })
31
+
32
+ it('keeps the payload widget rule and rejects an interval below the platform floor', () => {
33
+ const resolution = resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: 5 })
34
+
35
+ expect(resolution.kind).toBe('invalid')
36
+ expect(resolution).toHaveProperty('error', expect.stringContaining('at least 15'))
37
+ })
38
+
39
+ it('accepts an interval below 15 on iOS payload widgets, where the floor is 1', () => {
40
+ expect(
41
+ resolveServerUpdateInterval({
42
+ ...PAYLOAD_WIDGET,
43
+ context: 'ios.widgets[portfolio].serverUpdate',
44
+ defaultIntervalMinutes: 15,
45
+ minimumIntervalMinutes: 1,
46
+ intervalMinutes: 5,
47
+ })
48
+ ).toEqual({ kind: 'ok', intervalMinutes: 5 })
49
+ })
50
+
51
+ it('clamps a widget with an entry up to 15 and warns instead of failing the build', () => {
52
+ const resolution = resolveServerUpdateInterval({
53
+ ...DYNAMIC_WIDGET,
54
+ minimumIntervalMinutes: 1,
55
+ intervalMinutes: 5,
56
+ })
57
+
58
+ expect(resolution).toEqual({
59
+ kind: 'clamped',
60
+ intervalMinutes: 15,
61
+ warning: expect.stringContaining('below the 15 minute floor'),
62
+ })
63
+ })
64
+
65
+ it('accepts an interval at or above 15 for a widget with an entry', () => {
66
+ expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: 30 })).toEqual({
67
+ kind: 'ok',
68
+ intervalMinutes: 30,
69
+ })
70
+ })
71
+
72
+ it('rejects a non-integer or non-finite interval on either engine', () => {
73
+ expect(resolveServerUpdateInterval({ ...DYNAMIC_WIDGET, intervalMinutes: 15.5 }).kind).toBe('invalid')
74
+ expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: Number.NaN }).kind).toBe('invalid')
75
+ expect(resolveServerUpdateInterval({ ...PAYLOAD_WIDGET, intervalMinutes: '30' }).kind).toBe('invalid')
76
+ })
77
+ })
78
+
79
+ describe('resolveServerUpdateUrl', () => {
80
+ const context = 'ios.widgets[portfolio].serverUpdate'
81
+
82
+ it('accepts an absent url, so the app can supply one at runtime', () => {
83
+ expect(resolveServerUpdateUrl(undefined, context)).toEqual({ kind: 'ok' })
84
+ })
85
+
86
+ it('accepts https', () => {
87
+ expect(resolveServerUpdateUrl('https://api.example.com/widgets/portfolio', context)).toEqual({ kind: 'ok' })
88
+ })
89
+
90
+ it('accepts plain http for the dev hosts a simulator and an emulator reach', () => {
91
+ expect(resolveServerUpdateUrl('http://localhost:3333', context)).toEqual({ kind: 'ok' })
92
+ expect(resolveServerUpdateUrl('http://10.0.2.2:3333/widgets', context)).toEqual({ kind: 'ok' })
93
+ expect(resolveServerUpdateUrl('http://127.0.0.1:3333', context)).toEqual({ kind: 'ok' })
94
+ })
95
+
96
+ it('warns rather than failing on plain http to another host, which release builds block', () => {
97
+ const resolution = resolveServerUpdateUrl('http://192.168.1.5:3333', context)
98
+
99
+ expect(resolution.kind).toBe('insecure')
100
+ expect(resolution).toHaveProperty('warning', expect.stringContaining('cleartext'))
101
+ })
102
+
103
+ it('rejects a url with no scheme, which no platform HTTP stack accepts', () => {
104
+ expect(resolveServerUpdateUrl('api.example.com/widgets', context).kind).toBe('invalid')
105
+ })
106
+
107
+ it('rejects a non-http scheme', () => {
108
+ expect(resolveServerUpdateUrl('ftp://api.example.com', context).kind).toBe('invalid')
109
+ })
110
+
111
+ it('rejects an empty url', () => {
112
+ expect(resolveServerUpdateUrl(' ', context).kind).toBe('invalid')
113
+ expect(resolveServerUpdateUrl(42, context).kind).toBe('invalid')
114
+ })
115
+ })
116
+
117
+ describe('validateServerUpdateRefresh', () => {
118
+ it('accepts a boolean or nothing', () => {
119
+ expect(validateServerUpdateRefresh(undefined, 'ctx')).toBeUndefined()
120
+ expect(validateServerUpdateRefresh(true, 'ctx')).toBeUndefined()
121
+ })
122
+
123
+ it('rejects a non-boolean', () => {
124
+ expect(validateServerUpdateRefresh('yes', 'ctx')).toContain('must be a boolean')
125
+ })
126
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Config rules for `serverUpdate`, shared by the Expo plugins and the `voltra` CLI.
3
+ *
4
+ * `serverUpdate` marks a widget as server-driven for both render engines. On a widget with
5
+ * `entry` the device fetches a JSON object and hands it to the bundled JS as props; without
6
+ * `entry` the server returns a full Voltra payload. Widgets without `entry` keep exactly the
7
+ * rules they had before ADR 0002, so no existing config breaks.
8
+ *
9
+ * `url` is optional. `serverUpdate: {}` means "server-driven, URL supplied at runtime" through
10
+ * `setWidgetServerUpdate`, which covers per-tenant backends whose URL is only known after login.
11
+ *
12
+ * These helpers report problems instead of throwing so each caller can raise its own error type.
13
+ *
14
+ * The `voltra` CLI validates `serverUpdate` from its own copy of this module; keep the two in
15
+ * sync.
16
+ */
17
+
18
+ /**
19
+ * Interval floor and default for a widget with `entry`. WorkManager cannot run periodic work
20
+ * more often than every 15 minutes, and WidgetKit stretches timelines requested closer together
21
+ * than five minutes, so a smaller number would only mislead.
22
+ */
23
+ export const DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES = 15
24
+
25
+ /** Hosts allowed over plain `http`, for talking to a dev server from a simulator or emulator. */
26
+ const LOCAL_HTTP_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '10.0.2.2', '10.0.3.2'])
27
+
28
+ /** Outcome of resolving `serverUpdate.intervalMinutes` against a platform's floor. */
29
+ export type ServerUpdateIntervalResolution =
30
+ | { kind: 'ok'; intervalMinutes: number }
31
+ | { kind: 'clamped'; intervalMinutes: number; warning: string }
32
+ | { kind: 'invalid'; error: string }
33
+
34
+ export interface ResolveServerUpdateIntervalOptions {
35
+ /** Raw `intervalMinutes` from app.json, if the widget set one. */
36
+ intervalMinutes: unknown
37
+ /** Config path used in messages, e.g. `android.widgets[portfolio].serverUpdate`. */
38
+ context: string
39
+ /** True when the widget has an `entry` and therefore renders on device. */
40
+ hasEntry: boolean
41
+ /** Interval used when the widget does not set one. Ignored for widgets with `entry`. */
42
+ defaultIntervalMinutes: number
43
+ /** Platform floor for payload widgets. Ignored for widgets with `entry`. */
44
+ minimumIntervalMinutes: number
45
+ }
46
+
47
+ /**
48
+ * Resolves the interval a widget should be scheduled on.
49
+ *
50
+ * A widget with `entry` is clamped up to {@link DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}
51
+ * with a warning, because a shorter interval is not something either platform can honour. A
52
+ * payload widget keeps the platform's existing rule and is rejected below the floor.
53
+ */
54
+ export function resolveServerUpdateInterval(
55
+ options: ResolveServerUpdateIntervalOptions
56
+ ): ServerUpdateIntervalResolution {
57
+ const { intervalMinutes, context, hasEntry, defaultIntervalMinutes, minimumIntervalMinutes } = options
58
+ const fallback = hasEntry ? DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES : defaultIntervalMinutes
59
+
60
+ if (intervalMinutes === undefined) {
61
+ return { kind: 'ok', intervalMinutes: fallback }
62
+ }
63
+
64
+ if (typeof intervalMinutes !== 'number' || !Number.isFinite(intervalMinutes)) {
65
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be a number` }
66
+ }
67
+
68
+ if (!Number.isInteger(intervalMinutes)) {
69
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be an integer` }
70
+ }
71
+
72
+ if (hasEntry) {
73
+ if (intervalMinutes < DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES) {
74
+ return {
75
+ kind: 'clamped',
76
+ intervalMinutes: DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES,
77
+ warning:
78
+ `${context}.intervalMinutes is ${intervalMinutes}, below the ` +
79
+ `${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES} minute floor for widgets with an entry. ` +
80
+ `Using ${DYNAMIC_WIDGET_SERVER_UPDATE_INTERVAL_MINUTES}.`,
81
+ }
82
+ }
83
+
84
+ return { kind: 'ok', intervalMinutes }
85
+ }
86
+
87
+ if (intervalMinutes < minimumIntervalMinutes) {
88
+ return { kind: 'invalid', error: `${context}.intervalMinutes must be at least ${minimumIntervalMinutes}` }
89
+ }
90
+
91
+ return { kind: 'ok', intervalMinutes }
92
+ }
93
+
94
+ /** Outcome of checking `serverUpdate.url`. */
95
+ export type ServerUpdateUrlResolution =
96
+ | { kind: 'ok' }
97
+ | { kind: 'insecure'; warning: string }
98
+ | { kind: 'invalid'; error: string }
99
+
100
+ /**
101
+ * Checks `serverUpdate.url`. An absent URL is fine — the app supplies it at runtime through
102
+ * `setWidgetServerUpdate`.
103
+ *
104
+ * A URL that is not an absolute `http`/`https` URL is rejected: no platform HTTP stack here
105
+ * accepts one, so such a config could only ever have failed to fetch. Plain `http` to a
106
+ * non-local host is reported as insecure rather than rejected — App Transport Security and
107
+ * Android's cleartext policy already block it in a release build, and rejecting it outright
108
+ * would break configs that point at a LAN dev server.
109
+ */
110
+ export function resolveServerUpdateUrl(url: unknown, context: string): ServerUpdateUrlResolution {
111
+ if (url === undefined) {
112
+ return { kind: 'ok' }
113
+ }
114
+
115
+ if (typeof url !== 'string' || !url.trim()) {
116
+ return { kind: 'invalid', error: `${context}.url must be a non-empty string` }
117
+ }
118
+
119
+ let parsed: URL
120
+
121
+ try {
122
+ parsed = new URL(url)
123
+ } catch {
124
+ return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
125
+ }
126
+
127
+ if (parsed.protocol === 'https:') {
128
+ return { kind: 'ok' }
129
+ }
130
+
131
+ if (parsed.protocol !== 'http:') {
132
+ return { kind: 'invalid', error: `${context}.url must be an absolute http(s) URL, received '${url}'` }
133
+ }
134
+
135
+ if (isLocalHttpHost(parsed.hostname)) {
136
+ return { kind: 'ok' }
137
+ }
138
+
139
+ return {
140
+ kind: 'insecure',
141
+ warning:
142
+ `${context}.url uses plain http ('${url}'). Release builds block cleartext traffic, so the ` +
143
+ 'widget will not fetch outside a development build. Use https, or a local dev host ' +
144
+ `(${[...LOCAL_HTTP_HOSTS].join(', ')}).`,
145
+ }
146
+ }
147
+
148
+ /** True for the hosts Voltra allows over plain `http` — dev servers reachable from a simulator. */
149
+ export function isLocalHttpHost(hostname: string): boolean {
150
+ return LOCAL_HTTP_HOSTS.has(hostname.replace(/^\[|\]$/g, ''))
151
+ }
152
+
153
+ /** Validates `serverUpdate.refresh`. Returns an error message, or `undefined` when it is fine. */
154
+ export function validateServerUpdateRefresh(refresh: unknown, context: string): string | undefined {
155
+ if (refresh !== undefined && typeof refresh !== 'boolean') {
156
+ return `${context}.refresh must be a boolean`
157
+ }
158
+
159
+ return undefined
160
+ }
package/src/types.ts CHANGED
@@ -26,6 +26,31 @@ export interface DynamicWidgetManifest {
26
26
  widgets: DynamicWidgetManifestWidget[]
27
27
  }
28
28
 
29
+ /**
30
+ * Generated by the iOS config plugin for Metro's Dynamic Live Activity pipeline.
31
+ * This intentionally does not share the Dynamic Widget manifest: an ID may exist in
32
+ * both collections and each collection has a separate runtime and bundle namespace.
33
+ */
34
+ export interface DynamicLiveActivityManifestDefinition {
35
+ id: string
36
+ entry: string
37
+ }
38
+
39
+ export interface DynamicLiveActivityManifest {
40
+ version: 1
41
+ platform: 'ios'
42
+ liveActivities: DynamicLiveActivityManifestDefinition[]
43
+ }
44
+
45
+ /**
46
+ * Shared app.json entry contract for Dynamic Live Activities.
47
+ * @experimental
48
+ */
49
+ export interface DynamicLiveActivityEntryConfig {
50
+ id: string
51
+ entry: string
52
+ }
53
+
29
54
  /**
30
55
  * Per-locale strings for widget picker/gallery labels (`displayName`, `description`).
31
56
  * Keys should be BCP-47-style locale tags (e.g. `en`, `pl`, `pt-BR`). Plain `string` is still allowed for a single-language setup.
@@ -0,0 +1,24 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ import { resolveInstalledPackageVersion } from './packageVersion'
6
+
7
+ describe('resolveInstalledPackageVersion', () => {
8
+ it('reads the version from the package installed in the project', () => {
9
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-package-version-'))
10
+ const packageDir = path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-client')
11
+ fs.mkdirSync(packageDir, { recursive: true })
12
+ fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}\n')
13
+ fs.writeFileSync(
14
+ path.join(packageDir, 'package.json'),
15
+ `${JSON.stringify({ name: '@use-voltra/ios-client', version: '9.8.7' })}\n`
16
+ )
17
+
18
+ try {
19
+ expect(resolveInstalledPackageVersion(projectRoot, '@use-voltra/ios-client')).toBe('9.8.7')
20
+ } finally {
21
+ fs.rmSync(projectRoot, { recursive: true, force: true })
22
+ }
23
+ })
24
+ })
@@ -0,0 +1,19 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+
5
+ interface PackageManifest {
6
+ version?: unknown
7
+ }
8
+
9
+ export function resolveInstalledPackageVersion(projectRoot: string, packageName: string): string {
10
+ const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
11
+ const packageJsonPath = projectRequire.resolve(`${packageName}/package.json`)
12
+ const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageManifest
13
+
14
+ if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
15
+ throw new Error(`Package ${packageName} does not declare a valid version in ${packageJsonPath}.`)
16
+ }
17
+
18
+ return manifest.version
19
+ }
@@ -54,7 +54,8 @@ describe('prerenderWidgetState', () => {
54
54
  const states = await prerenderWidgetState(
55
55
  [{ id: 'demo', initialStatePath: './widgets/state.ts' }],
56
56
  tempRoot,
57
- (variants) => JSON.stringify(variants)
57
+ (variants) => JSON.stringify(variants),
58
+ targetLabel
58
59
  )
59
60
 
60
61
  expect(states.get('demo')?.get('__default')).toBe(JSON.stringify({ label: targetLabel }))