@use-voltra/expo-plugin 2.1.1 → 2.3.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.
- package/build/cjs/constants.js +1 -3
- package/build/cjs/constants.js.map +1 -1
- package/build/cjs/dynamic-live-activity.js +6 -0
- package/build/cjs/dynamic-live-activity.js.map +1 -0
- package/build/cjs/index.js +15 -2
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/serverUpdate.js +119 -0
- package/build/cjs/serverUpdate.js.map +1 -0
- package/build/cjs/utils/packageVersion.js +49 -0
- package/build/cjs/utils/packageVersion.js.map +1 -0
- package/build/cjs/utils/prerender.js +25 -159
- package/build/cjs/utils/prerender.js.map +1 -1
- package/build/cjs/widgetServerUpdate.js +63 -0
- package/build/cjs/widgetServerUpdate.js.map +1 -0
- package/build/esm/constants.js +0 -2
- package/build/esm/constants.js.map +1 -1
- package/build/esm/dynamic-live-activity.js +2 -0
- package/build/esm/dynamic-live-activity.js.map +1 -0
- package/build/esm/index.js +6 -2
- package/build/esm/index.js.map +1 -1
- package/build/esm/serverUpdate.js +112 -0
- package/build/esm/serverUpdate.js.map +1 -0
- package/build/esm/utils/packageVersion.js +13 -0
- package/build/esm/utils/packageVersion.js.map +1 -0
- package/build/esm/utils/prerender.js +24 -126
- package/build/esm/utils/prerender.js.map +1 -1
- package/build/esm/widgetServerUpdate.js +59 -0
- package/build/esm/widgetServerUpdate.js.map +1 -0
- package/build/types/constants.d.ts +0 -2
- package/build/types/constants.d.ts.map +1 -1
- package/build/types/dynamic-live-activity.d.ts +2 -0
- package/build/types/dynamic-live-activity.d.ts.map +1 -0
- package/build/types/index.d.ts +11 -4
- package/build/types/index.d.ts.map +1 -1
- package/build/types/serverUpdate.d.ts +80 -0
- package/build/types/serverUpdate.d.ts.map +1 -0
- package/build/types/types.d.ts +22 -0
- package/build/types/types.d.ts.map +1 -1
- package/build/types/utils/packageVersion.d.ts +2 -0
- package/build/types/utils/packageVersion.d.ts.map +1 -0
- package/build/types/utils/prerender.d.ts +22 -8
- package/build/types/utils/prerender.d.ts.map +1 -1
- package/build/types/widgetServerUpdate.d.ts +34 -0
- package/build/types/widgetServerUpdate.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/constants.ts +0 -3
- package/src/dynamic-live-activity.node.test.ts +18 -0
- package/src/dynamic-live-activity.ts +1 -0
- package/src/index.ts +33 -3
- package/src/serverUpdate.node.test.ts +126 -0
- package/src/serverUpdate.ts +160 -0
- package/src/types.ts +25 -0
- package/src/utils/packageVersion.node.test.ts +24 -0
- package/src/utils/packageVersion.ts +19 -0
- package/src/utils/prerender.node.test.ts +2 -1
- package/src/utils/prerender.ts +34 -155
- 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
|
|
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.
|
|
22
|
-
*
|
|
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(
|
|
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(
|
|
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":"
|
|
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.
|
|
3
|
+
"version": "2.3.0",
|
|
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
|
-
"@
|
|
23
|
+
"@use-voltra/compiler": "^2.3.0",
|
|
24
|
+
"@use-voltra/core": "^2.3.0"
|
|
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
|
@@ -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
|
|
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
|
|
24
|
-
export {
|
|
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 }))
|