@lynxship/expo 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -7,7 +7,12 @@ embedded `main.lynx.bundle` fallback.
7
7
 
8
8
  ## Install
9
9
 
10
+ Build the Lynx output before the native prebuild. The plugin then copies the
11
+ whole Rspeedy output directory, including `static/` assets, into the generated
12
+ Android and iOS hosts and records hashes in a managed manifest.
13
+
10
14
  ```bash
15
+ lynxship build --local
11
16
  npx expo install @lynxship/expo
12
17
  npx expo prebuild
13
18
  npx pod-install
@@ -37,6 +42,8 @@ options are needed, add the config plugin manually:
37
42
  "release-key-1": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
38
43
  },
39
44
  "embeddedBundle": "main.lynx.bundle",
45
+ "bundlePath": "../lynx-app/dist/main.lynx.bundle",
46
+ "syncBundle": true,
40
47
  "lynxVersion": "auto"
41
48
  }
42
49
  ]
@@ -49,6 +56,19 @@ For a dynamic config, keep the same `plugins` entry in the returned Expo
49
56
  configuration. Expo cannot safely rewrite JavaScript or TypeScript config
50
57
  files automatically.
51
58
 
59
+ `bundlePath` points to the generated `.lynx.bundle` and is resolved relative
60
+ to the Expo project. All files beside that bundle are copied so Rspeedy's
61
+ `static/` resources remain available at runtime. The default is
62
+ `dist/main.lynx.bundle`. `syncBundle` defaults to `true`; set it to `false`
63
+ only when a different native asset pipeline owns the bundle. Prebuild fails
64
+ with a repair command if the configured bundle is missing, rather than
65
+ producing a native app that opens to a blank Lynx view.
66
+
67
+ The sync is deterministic and idempotent. LynxShip updates files previously
68
+ owned by its manifest, removes only stale files from that manifest, and refuses
69
+ to overwrite a developer-owned native asset. The iOS output is added as a
70
+ folder resource so nested asset paths are preserved.
71
+
52
72
  The endpoint must be HTTPS outside localhost. The public verification key is
53
73
  safe to ship in the application; private signing keys must remain in the
54
74
  LynxShip CLI or CI secret store.
@@ -88,7 +108,7 @@ modules and Lynx runtime changes still require a new binary.
88
108
 
89
109
  ## Build workflow
90
110
 
91
- From the Expo project:
111
+ From the Lynx project, after the bundle has been generated:
92
112
 
93
113
  ```bash
94
114
  lynxship init
@@ -100,6 +120,20 @@ lynxship ota doctor --platform android
100
120
  lynxship ota doctor --platform ios
101
121
  ```
102
122
 
123
+ For a separate Lynx and Expo checkout, keep the bundle path in the Expo plugin
124
+ configuration and run the two build stages in this order:
125
+
126
+ ```bash
127
+ lynxship --project-dir ../lynx-app build --local
128
+ npx expo prebuild --clean
129
+ npx eas-cli@latest build --platform android --profile preview
130
+ ```
131
+
132
+ EAS receives the Expo project directory, so the Lynx bundle must be generated
133
+ and embedded before the cloud upload (or by an explicitly configured CI build
134
+ step). Config plugins do not run arbitrary network downloads or compilers
135
+ during `prebuild`.
136
+
103
137
  The package does not silently replace an existing native host. `expo prebuild`
104
138
  and the official Lynx dependencies remain the source of truth for native
105
139
  project generation. iOS builds still require macOS/Xcode; Android builds use
package/app.plugin.cjs CHANGED
@@ -1,10 +1,15 @@
1
1
  const {
2
2
  withAndroidManifest,
3
+ withDangerousMod,
3
4
  withGradleProperties,
4
5
  withInfoPlist,
5
6
  withPodfile,
6
7
  withSettingsGradle,
8
+ withXcodeProject,
9
+ IOSConfig,
7
10
  } = require("@expo/config-plugins");
11
+ const path = require("node:path");
12
+ const { syncLynxAssets } = require("./asset-sync.cjs");
8
13
 
9
14
  const MARKER = "# @lynxship/expo managed";
10
15
  const DEFAULT_LYNX_VERSION = "auto";
@@ -58,6 +63,33 @@ function assertOptions(options) {
58
63
  "@lynxship/expo lynxVersion must be auto, latest, or an exact semver",
59
64
  );
60
65
  }
66
+ if (
67
+ options.bundlePath !== undefined &&
68
+ (typeof options.bundlePath !== "string" ||
69
+ options.bundlePath.trim() === "" ||
70
+ options.bundlePath.includes("\0"))
71
+ ) {
72
+ throw new Error(
73
+ "@lynxship/expo bundlePath must be a non-empty path without null bytes",
74
+ );
75
+ }
76
+ if (
77
+ options.syncBundle !== undefined &&
78
+ typeof options.syncBundle !== "boolean"
79
+ )
80
+ throw new Error("@lynxship/expo syncBundle must be a boolean");
81
+ if (
82
+ options.embeddedBundle !== undefined &&
83
+ (typeof options.embeddedBundle !== "string" ||
84
+ options.embeddedBundle.trim() === "" ||
85
+ options.embeddedBundle.startsWith("/") ||
86
+ options.embeddedBundle.includes("\\") ||
87
+ options.embeddedBundle.split("/").includes(".."))
88
+ ) {
89
+ throw new Error(
90
+ "@lynxship/expo embeddedBundle must be a portable relative path without '..'",
91
+ );
92
+ }
61
93
  return options;
62
94
  }
63
95
 
@@ -185,20 +217,84 @@ function withLynxShipPodfile(config, options) {
185
217
  });
186
218
  }
187
219
 
220
+ function withLynxShipAndroidAssets(config, options) {
221
+ if (options.syncBundle === false) return config;
222
+ return withDangerousMod(config, [
223
+ "android",
224
+ async (value) => {
225
+ await syncLynxAssets(value.modRequest.projectRoot, {
226
+ ...options,
227
+ platform: "android",
228
+ });
229
+ return value;
230
+ },
231
+ ]);
232
+ }
233
+
234
+ function addIosFolderResource(project, folderPath, groupName) {
235
+ const group = project.pbxGroupByName(groupName);
236
+ if (!group)
237
+ throw new Error(`@lynxship/expo could not find iOS group ${groupName}`);
238
+ const groups = project.hash.project.objects.PBXGroup || {};
239
+ const groupKey = Object.keys(groups).find(
240
+ (key) => !key.endsWith("_comment") && groups[key] === group,
241
+ );
242
+ if (!groupKey)
243
+ throw new Error(`@lynxship/expo could not resolve iOS group ${groupName}`);
244
+ const existing = group.children?.find(
245
+ (child) => child.comment === path.basename(folderPath),
246
+ );
247
+ if (existing) return;
248
+ const file = project.addFile(folderPath, groupKey, {
249
+ lastKnownFileType: "folder",
250
+ sourceTree: "<group>",
251
+ });
252
+ if (!file) return;
253
+ file.uuid = project.generateUuid();
254
+ const target = project.getTarget("com.apple.product-type.application");
255
+ if (!target)
256
+ throw new Error("@lynxship/expo could not find the iOS application target");
257
+ file.target = target.uuid;
258
+ project.addToPbxBuildFileSection(file);
259
+ project.addToPbxResourcesBuildPhase(file);
260
+ }
261
+
262
+ function withLynxShipIosAssets(config, options) {
263
+ if (options.syncBundle === false) return config;
264
+ const withCopiedAssets = withDangerousMod(config, [
265
+ "ios",
266
+ async (value) => {
267
+ const sourceRoot = IOSConfig.Paths.getSourceRoot(
268
+ value.modRequest.projectRoot,
269
+ );
270
+ await syncLynxAssets(value.modRequest.projectRoot, {
271
+ ...options,
272
+ platform: "ios",
273
+ iosSourceRoot: sourceRoot,
274
+ });
275
+ return value;
276
+ },
277
+ ]);
278
+ return withXcodeProject(withCopiedAssets, (value) => {
279
+ const sourceRoot = IOSConfig.Paths.getSourceRoot(
280
+ value.modRequest.projectRoot,
281
+ );
282
+ const projectName = path.basename(sourceRoot);
283
+ const folderPath = `${projectName}/LynxShipAssets`;
284
+ addIosFolderResource(value.modResults, folderPath, projectName);
285
+ return value;
286
+ });
287
+ }
288
+
188
289
  function withLynxShipExpo(config, props = {}) {
189
290
  const options = assertOptions({ ...getOptions(config), ...props });
190
- return withLynxShipPodfile(
191
- withLynxShipAndroidSdk(
192
- withLynxShipGradle(
193
- withLynxShipInfoPlist(
194
- withLynxShipAndroidManifest(config, options),
195
- options,
196
- ),
197
- options,
198
- ),
199
- ),
200
- options,
201
- );
291
+ let result = withLynxShipAndroidManifest(config, options);
292
+ result = withLynxShipInfoPlist(result, options);
293
+ result = withLynxShipGradle(result, options);
294
+ result = withLynxShipAndroidSdk(result);
295
+ result = withLynxShipAndroidAssets(result, options);
296
+ result = withLynxShipIosAssets(result, options);
297
+ return withLynxShipPodfile(result, options);
202
298
  }
203
299
 
204
300
  module.exports = withLynxShipExpo;
package/asset-sync.cjs ADDED
@@ -0,0 +1,264 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const fsp = require("node:fs/promises");
4
+ const path = require("node:path");
5
+
6
+ const MANIFEST_VERSION = 1;
7
+
8
+ function isAbsoluteAny(value) {
9
+ return path.isAbsolute(value) || path.win32.isAbsolute(value);
10
+ }
11
+
12
+ function safeRelative(value, label) {
13
+ if (typeof value !== "string" || value.trim() === "") {
14
+ throw new Error(`@lynxship/expo ${label} must be a non-empty path`);
15
+ }
16
+ const normalized = value.replaceAll("\\", "/");
17
+ if (
18
+ isAbsoluteAny(value) ||
19
+ normalized.startsWith("/") ||
20
+ normalized.split("/").some((part) => part === "..") ||
21
+ normalized.includes("\0")
22
+ ) {
23
+ throw new Error(
24
+ `@lynxship/expo ${label} must be a portable relative path without '..'`,
25
+ );
26
+ }
27
+ return normalized.replace(/^\.\//, "");
28
+ }
29
+
30
+ function destinationPath(root, relativePath) {
31
+ const normalized = safeRelative(relativePath, "embeddedBundle");
32
+ const destination = path.resolve(root, ...normalized.split("/"));
33
+ const relative = path.relative(root, destination);
34
+ if (
35
+ relative === "" ||
36
+ relative.startsWith(".." + path.sep) ||
37
+ path.isAbsolute(relative)
38
+ ) {
39
+ throw new Error(
40
+ "@lynxship/expo embeddedBundle must stay in the asset root",
41
+ );
42
+ }
43
+ return { normalized, destination };
44
+ }
45
+
46
+ async function collectFiles(directory, prefix = "") {
47
+ const entries = await fsp.readdir(directory, { withFileTypes: true });
48
+ entries.sort((left, right) => left.name.localeCompare(right.name));
49
+ const files = [];
50
+ for (const entry of entries) {
51
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
52
+ const absolute = path.join(directory, entry.name);
53
+ if (entry.isSymbolicLink()) {
54
+ throw new Error(
55
+ `@lynxship/expo refuses symbolic links in the Lynx output: ${relative}`,
56
+ );
57
+ }
58
+ if (entry.isDirectory()) {
59
+ files.push(...(await collectFiles(absolute, relative)));
60
+ } else if (entry.isFile()) {
61
+ files.push({ absolute, relative: relative.replaceAll("\\", "/") });
62
+ } else {
63
+ throw new Error(
64
+ `@lynxship/expo found an unsupported filesystem entry in the Lynx output: ${relative}`,
65
+ );
66
+ }
67
+ }
68
+ return files;
69
+ }
70
+
71
+ async function createBundlePlan(projectRoot, options = {}) {
72
+ const bundlePath = options.bundlePath || "dist/main.lynx.bundle";
73
+ if (typeof bundlePath !== "string" || bundlePath.trim() === "") {
74
+ throw new Error("@lynxship/expo bundlePath must be a non-empty path");
75
+ }
76
+ const sourceBundle = path.resolve(projectRoot, bundlePath);
77
+ const sourceDirectory = path.dirname(sourceBundle);
78
+ const sourceStat = await fsp.stat(sourceBundle).catch(() => undefined);
79
+ if (!sourceStat || !sourceStat.isFile() || sourceStat.size === 0) {
80
+ throw new Error(
81
+ `Lynx bundle not found or empty: ${sourceBundle}. Build Rspeedy first (for example: lynxship build --local), or set expo.plugins[@lynxship/expo].bundlePath to the generated .lynx.bundle.`,
82
+ );
83
+ }
84
+ const embeddedBundle = safeRelative(
85
+ options.embeddedBundle || "main.lynx.bundle",
86
+ "embeddedBundle",
87
+ );
88
+ const files = await collectFiles(sourceDirectory);
89
+ const plan = files.map((file) => ({
90
+ ...file,
91
+ destination:
92
+ path.resolve(sourceDirectory, file.relative) === sourceBundle
93
+ ? embeddedBundle
94
+ : safeRelative(file.relative, "bundle output file"),
95
+ }));
96
+ const seen = new Set();
97
+ for (const file of plan) {
98
+ if (seen.has(file.destination)) {
99
+ throw new Error(
100
+ `@lynxship/expo found duplicate embedded asset path: ${file.destination}`,
101
+ );
102
+ }
103
+ seen.add(file.destination);
104
+ }
105
+ return {
106
+ sourceBundle,
107
+ sourceDirectory,
108
+ bundlePath,
109
+ embeddedBundle,
110
+ files: plan,
111
+ };
112
+ }
113
+
114
+ async function sha256File(file) {
115
+ const hash = crypto.createHash("sha256");
116
+ hash.update(await fsp.readFile(file));
117
+ return hash.digest("hex");
118
+ }
119
+
120
+ async function readManifest(file) {
121
+ try {
122
+ const value = JSON.parse(await fsp.readFile(file, "utf8"));
123
+ if (value.version !== MANIFEST_VERSION || !Array.isArray(value.files)) {
124
+ return { files: [] };
125
+ }
126
+ return value;
127
+ } catch {
128
+ return { files: [] };
129
+ }
130
+ }
131
+
132
+ function manifestPaths(manifest) {
133
+ return new Set(
134
+ manifest.files
135
+ .map((file) => file && file.path)
136
+ .filter((file) => typeof file === "string")
137
+ .map((file) => safeRelative(file, "managed asset path")),
138
+ );
139
+ }
140
+
141
+ async function syncBundleDirectory({
142
+ projectRoot,
143
+ plan,
144
+ destinationRoot,
145
+ manifestPath,
146
+ platform,
147
+ }) {
148
+ await fsp.mkdir(destinationRoot, { recursive: true });
149
+ const previous = await readManifest(manifestPath);
150
+ const previouslyManaged = manifestPaths(previous);
151
+ const desired = new Set(plan.files.map((file) => file.destination));
152
+
153
+ for (const file of plan.files) {
154
+ const { destination } = destinationPath(destinationRoot, file.destination);
155
+ const existing = await fsp.lstat(destination).catch(() => undefined);
156
+ if (existing && !previouslyManaged.has(file.destination)) {
157
+ throw new Error(
158
+ `@lynxship/expo will not overwrite an unmanaged ${platform} asset: ${destination}. Choose another embeddedBundle path or remove the conflicting file yourself.`,
159
+ );
160
+ }
161
+ if (existing && !existing.isFile()) {
162
+ throw new Error(
163
+ `@lynxship/expo cannot replace a non-file ${platform} asset: ${destination}`,
164
+ );
165
+ }
166
+ }
167
+
168
+ for (const stale of previouslyManaged) {
169
+ if (desired.has(stale)) continue;
170
+ const { destination } = destinationPath(destinationRoot, stale);
171
+ const existing = await fsp.lstat(destination).catch(() => undefined);
172
+ if (existing?.isFile()) await fsp.rm(destination);
173
+ }
174
+
175
+ const manifestFiles = [];
176
+ for (const file of plan.files) {
177
+ const { destination } = destinationPath(destinationRoot, file.destination);
178
+ await fsp.mkdir(path.dirname(destination), { recursive: true });
179
+ await fsp.copyFile(file.absolute, destination);
180
+ const stat = await fsp.stat(destination);
181
+ manifestFiles.push({
182
+ path: file.destination,
183
+ size: stat.size,
184
+ sha256: await sha256File(destination),
185
+ });
186
+ }
187
+
188
+ await fsp.mkdir(path.dirname(manifestPath), { recursive: true });
189
+ const source = path
190
+ .relative(projectRoot, plan.sourceDirectory)
191
+ .replaceAll("\\", "/");
192
+ await fsp.writeFile(
193
+ manifestPath,
194
+ JSON.stringify(
195
+ {
196
+ version: MANIFEST_VERSION,
197
+ platform,
198
+ source: source || ".",
199
+ bundlePath: plan.bundlePath,
200
+ embeddedBundle: plan.embeddedBundle,
201
+ files: manifestFiles,
202
+ },
203
+ null,
204
+ 2,
205
+ ) + "\n",
206
+ "utf8",
207
+ );
208
+ return {
209
+ platform,
210
+ sourceBundle: plan.sourceBundle,
211
+ destinationRoot,
212
+ manifestPath,
213
+ files: manifestFiles,
214
+ };
215
+ }
216
+
217
+ async function syncLynxAssets(projectRoot, options = {}) {
218
+ const plan = await createBundlePlan(projectRoot, options);
219
+ const platform = options.platform;
220
+ if (platform === "android") {
221
+ const destinationRoot = path.join(
222
+ projectRoot,
223
+ "android",
224
+ "app",
225
+ "src",
226
+ "main",
227
+ "assets",
228
+ );
229
+ if (!fs.existsSync(path.join(projectRoot, "android"))) {
230
+ throw new Error(
231
+ "Android native project is missing. Run `npx expo prebuild --platform android` before syncing the Lynx bundle.",
232
+ );
233
+ }
234
+ return syncBundleDirectory({
235
+ projectRoot,
236
+ plan,
237
+ destinationRoot,
238
+ manifestPath: path.join(projectRoot, "android", ".lynxship-assets.json"),
239
+ platform,
240
+ });
241
+ }
242
+ if (platform === "ios") {
243
+ const iosSourceRoot = options.iosSourceRoot;
244
+ if (!iosSourceRoot) {
245
+ throw new Error(
246
+ "iOS native source root is unavailable. Run `npx expo prebuild --platform ios` before syncing the Lynx bundle.",
247
+ );
248
+ }
249
+ return syncBundleDirectory({
250
+ projectRoot,
251
+ plan,
252
+ destinationRoot: path.join(iosSourceRoot, "LynxShipAssets"),
253
+ manifestPath: path.join(projectRoot, "ios", ".lynxship-assets.json"),
254
+ platform,
255
+ });
256
+ }
257
+ throw new Error(`@lynxship/expo does not support asset sync for ${platform}`);
258
+ }
259
+
260
+ module.exports = {
261
+ createBundlePlan,
262
+ syncBundleDirectory,
263
+ syncLynxAssets,
264
+ };
package/dist/config.d.ts CHANGED
@@ -5,6 +5,10 @@ export interface LynxShipExpoConfig {
5
5
  runtimeVersion?: string;
6
6
  publicKeys?: Record<string, string>;
7
7
  embeddedBundle?: string;
8
+ /** Relative path to the generated .lynx.bundle, normally dist/main.lynx.bundle. */
9
+ bundlePath?: string;
10
+ /** Copy the Lynx output into generated Android/iOS hosts during prebuild. */
11
+ syncBundle?: boolean;
8
12
  lynxVersion?: string;
9
13
  maxReleaseBytes?: number;
10
14
  }
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAKD,wBAAgB,0BAA0B,CACxC,KAAK,GAAE,kBAAuB,GAC7B,kBAAkB,CAkCpB"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mFAAmF;IACnF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAKD,wBAAgB,0BAA0B,CACxC,KAAK,GAAE,kBAAuB,GAC7B,kBAAkB,CAgDpB"}
package/dist/config.js CHANGED
@@ -17,6 +17,14 @@ function validateLynxShipExpoConfig(value = {}) {
17
17
  if (!Number.isInteger(value.maxReleaseBytes) || value.maxReleaseBytes < 1)
18
18
  throw new Error("LynxShip Expo maxReleaseBytes must be a positive integer");
19
19
  }
20
+ if (value.bundlePath !== undefined) {
21
+ if (typeof value.bundlePath !== "string" ||
22
+ value.bundlePath.trim().length === 0 ||
23
+ value.bundlePath.includes("\0"))
24
+ throw new Error("LynxShip Expo bundlePath must be a non-empty path without null bytes");
25
+ }
26
+ if (value.syncBundle !== undefined && typeof value.syncBundle !== "boolean")
27
+ throw new Error("LynxShip Expo syncBundle must be a boolean");
20
28
  if (value.lynxVersion !== undefined &&
21
29
  value.lynxVersion !== "auto" &&
22
30
  value.lynxVersion !== "latest" &&
@@ -27,6 +35,8 @@ function validateLynxShipExpoConfig(value = {}) {
27
35
  ...value,
28
36
  channel: value.channel ?? "production",
29
37
  embeddedBundle: value.embeddedBundle ?? "main.lynx.bundle",
38
+ bundlePath: value.bundlePath ?? "dist/main.lynx.bundle",
39
+ syncBundle: value.syncBundle ?? true,
30
40
  lynxVersion: value.lynxVersion ?? DEFAULT_LYNX_VERSION,
31
41
  };
32
42
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ComponentType } from "react";
1
+ import { type ComponentType, type ReactNode } from "react";
2
2
  import type { ViewProps } from "react-native";
3
3
  import { type LynxShipExpoConfig } from "./config.js";
4
4
  export type { LynxShipExpoConfig } from "./config.js";
@@ -15,7 +15,7 @@ export interface LynxViewProps extends ViewProps {
15
15
  * A LynxView that can be placed anywhere in an Expo/React Native view tree.
16
16
  * Native OTA configuration is supplied by the LynxShip config plugin.
17
17
  */
18
- export declare function LynxView(props: LynxViewProps): unknown;
18
+ export declare function LynxView(props: LynxViewProps): ReactNode;
19
19
  export declare const LynxShipView: ComponentType<LynxViewProps>;
20
20
  export declare function defineLynxShipExpoConfig(config: LynxShipExpoConfig): LynxShipExpoConfig;
21
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,OAAO,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAOtD;AAED,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,aAAa,CAAY,CAAC;AAEnE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,kBAAkB,GACzB,kBAAkB,CAEpB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE1E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAOxD;AAED,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,aAAa,CAAY,CAAC;AAEnE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,kBAAkB,GACzB,kBAAkB,CAEpB"}
@@ -113,7 +113,10 @@ public final class LynxShipExpoView: ExpoView {
113
113
  guard !path.isEmpty, !path.hasPrefix("/"), !path.contains(".."), !path.contains("\\") else {
114
114
  throw NSError(domain: "LynxShip", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unsafe Lynx asset path"])
115
115
  }
116
- guard let url = Bundle.main.url(forResource: path, withExtension: nil) ?? Bundle.main.url(forResource: path, withExtension: "bundle") else {
116
+ guard let url = Bundle.main.url(forResource: path, withExtension: nil, subdirectory: "LynxShipAssets")
117
+ ?? Bundle.main.url(forResource: path, withExtension: "bundle", subdirectory: "LynxShipAssets")
118
+ ?? Bundle.main.url(forResource: path, withExtension: nil)
119
+ ?? Bundle.main.url(forResource: path, withExtension: "bundle") else {
117
120
  throw NSError(domain: "LynxShip", code: 3, userInfo: [NSLocalizedDescriptionKey: "Embedded Lynx bundle not found: \(path)"])
118
121
  }
119
122
  return try Data(contentsOf: url)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lynxship/expo",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Expo native LynxView with LynxShip OTA delivery and cache support.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -12,6 +12,7 @@
12
12
  "src",
13
13
  "app.plugin.js",
14
14
  "app.plugin.cjs",
15
+ "asset-sync.cjs",
15
16
  "expo-module.config.json",
16
17
  "lynxship-expo.podspec",
17
18
  "README.md"
package/src/config.ts CHANGED
@@ -5,6 +5,10 @@ export interface LynxShipExpoConfig {
5
5
  runtimeVersion?: string;
6
6
  publicKeys?: Record<string, string>;
7
7
  embeddedBundle?: string;
8
+ /** Relative path to the generated .lynx.bundle, normally dist/main.lynx.bundle. */
9
+ bundlePath?: string;
10
+ /** Copy the Lynx output into generated Android/iOS hosts during prebuild. */
11
+ syncBundle?: boolean;
8
12
  lynxVersion?: string;
9
13
  maxReleaseBytes?: number;
10
14
  }
@@ -32,6 +36,18 @@ export function validateLynxShipExpoConfig(
32
36
  "LynxShip Expo maxReleaseBytes must be a positive integer",
33
37
  );
34
38
  }
39
+ if (value.bundlePath !== undefined) {
40
+ if (
41
+ typeof value.bundlePath !== "string" ||
42
+ value.bundlePath.trim().length === 0 ||
43
+ value.bundlePath.includes("\0")
44
+ )
45
+ throw new Error(
46
+ "LynxShip Expo bundlePath must be a non-empty path without null bytes",
47
+ );
48
+ }
49
+ if (value.syncBundle !== undefined && typeof value.syncBundle !== "boolean")
50
+ throw new Error("LynxShip Expo syncBundle must be a boolean");
35
51
  if (
36
52
  value.lynxVersion !== undefined &&
37
53
  value.lynxVersion !== "auto" &&
@@ -46,6 +62,8 @@ export function validateLynxShipExpoConfig(
46
62
  ...value,
47
63
  channel: value.channel ?? "production",
48
64
  embeddedBundle: value.embeddedBundle ?? "main.lynx.bundle",
65
+ bundlePath: value.bundlePath ?? "dist/main.lynx.bundle",
66
+ syncBundle: value.syncBundle ?? true,
49
67
  lynxVersion: value.lynxVersion ?? DEFAULT_LYNX_VERSION,
50
68
  };
51
69
  }
@@ -1,11 +1,12 @@
1
1
  declare module "react" {
2
+ export type ReactNode = unknown;
2
3
  export type ComponentType<Props = Record<string, unknown>> = (
3
4
  props: Props,
4
- ) => unknown;
5
+ ) => ReactNode;
5
6
  export function createElement(
6
7
  type: unknown,
7
8
  props: Record<string, unknown> | null,
8
- ): unknown;
9
+ ): ReactNode;
9
10
  }
10
11
 
11
12
  declare module "react-native" {
@@ -19,5 +20,5 @@ declare module "react-native" {
19
20
  declare module "expo-modules-core" {
20
21
  export function requireNativeViewManager<Props = Record<string, unknown>>(
21
22
  moduleName: string,
22
- ): (props: Props) => unknown;
23
+ ): (props: Props) => import("react").ReactNode;
23
24
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createElement, type ComponentType } from "react";
1
+ import { createElement, type ComponentType, type ReactNode } from "react";
2
2
  import { requireNativeViewManager } from "expo-modules-core";
3
3
  import type { ViewProps } from "react-native";
4
4
  import {
@@ -25,7 +25,7 @@ const NativeLynxView = requireNativeViewManager<LynxViewProps>("LynxShip");
25
25
  * A LynxView that can be placed anywhere in an Expo/React Native view tree.
26
26
  * Native OTA configuration is supplied by the LynxShip config plugin.
27
27
  */
28
- export function LynxView(props: LynxViewProps): unknown {
28
+ export function LynxView(props: LynxViewProps): ReactNode {
29
29
  return createElement(NativeLynxView, {
30
30
  bundle: props.bundle ?? "main.lynx.bundle",
31
31
  initialData: props.initialData ?? "",