@everystack/cli 0.1.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/README.md +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/s3.ts +125 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
2
|
+
import type { StorageAdapter } from '../storage/index';
|
|
3
|
+
import { convertSHA256HashToUUID } from './signing';
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
export interface ChannelMetadata {
|
|
10
|
+
name: string;
|
|
11
|
+
branchRef: string;
|
|
12
|
+
status: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManifestAssetRecord {
|
|
18
|
+
s3Key: string;
|
|
19
|
+
contentHash: string;
|
|
20
|
+
filename: string;
|
|
21
|
+
mimeType: string;
|
|
22
|
+
fileExtension: string;
|
|
23
|
+
isLaunchAsset: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Response helpers
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
export function jsonError(status: number, message: string): Response {
|
|
31
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
32
|
+
status,
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Auth
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export async function checkAuth(request: Request, options: UpdatesHandlerOptions): Promise<Response | null> {
|
|
42
|
+
if (!options.auth) return null;
|
|
43
|
+
|
|
44
|
+
const authHeader = request.headers.get('authorization');
|
|
45
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
46
|
+
return jsonError(401, 'Authentication required.');
|
|
47
|
+
}
|
|
48
|
+
const token = authHeader.slice(7);
|
|
49
|
+
const payload = await options.auth.verifyToken(token);
|
|
50
|
+
if (!payload) {
|
|
51
|
+
return jsonError(401, 'Invalid token.');
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// DB mirror — optional, non-blocking
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
export async function mirrorToDb(db: any | undefined, fn: () => Promise<void>): Promise<void> {
|
|
61
|
+
if (!db) return;
|
|
62
|
+
try {
|
|
63
|
+
await fn();
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.warn('[updates] DB mirror write failed:', err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Manifest builder
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a complete Expo manifest JSON from publish-time data.
|
|
75
|
+
* Produces the same shape that manifest.ts builds from DB queries,
|
|
76
|
+
* so the manifest can be written to S3 and served directly.
|
|
77
|
+
*/
|
|
78
|
+
export function buildManifestJson(
|
|
79
|
+
updateId: string,
|
|
80
|
+
runtimeVersion: string,
|
|
81
|
+
createdAt: string,
|
|
82
|
+
assetRecords: ManifestAssetRecord[],
|
|
83
|
+
expoConfig: Record<string, unknown> | undefined,
|
|
84
|
+
baseUrl: string,
|
|
85
|
+
basePath: string,
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const launchAsset = assetRecords.find(a => a.isLaunchAsset);
|
|
88
|
+
const regularAssets = assetRecords.filter(a => !a.isLaunchAsset);
|
|
89
|
+
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
|
90
|
+
|
|
91
|
+
const buildAssetEntry = (a: ManifestAssetRecord) => ({
|
|
92
|
+
hash: a.contentHash,
|
|
93
|
+
key: a.filename,
|
|
94
|
+
fileExtension: a.fileExtension,
|
|
95
|
+
contentType: a.mimeType,
|
|
96
|
+
url: `${cleanBaseUrl}${basePath}/assets?key=${a.s3Key}`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: convertSHA256HashToUUID(updateId),
|
|
101
|
+
createdAt,
|
|
102
|
+
runtimeVersion,
|
|
103
|
+
assets: regularAssets.map(buildAssetEntry),
|
|
104
|
+
launchAsset: launchAsset ? buildAssetEntry(launchAsset) : undefined,
|
|
105
|
+
metadata: {},
|
|
106
|
+
extra: {
|
|
107
|
+
expoClient: expoConfig || undefined,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// S3 channel management
|
|
114
|
+
// =============================================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read or create a channel metadata file in S3.
|
|
118
|
+
* Returns the channel metadata (existing or newly created).
|
|
119
|
+
*/
|
|
120
|
+
export async function ensureChannelInStorage(
|
|
121
|
+
storage: StorageAdapter,
|
|
122
|
+
channelName: string,
|
|
123
|
+
branchName: string,
|
|
124
|
+
): Promise<ChannelMetadata> {
|
|
125
|
+
const key = `_channels/${channelName}.json`;
|
|
126
|
+
const existing = await storage.get(key);
|
|
127
|
+
|
|
128
|
+
if (existing) {
|
|
129
|
+
const meta = JSON.parse(existing.data.toString('utf8')) as ChannelMetadata;
|
|
130
|
+
if (!meta.branchRef) {
|
|
131
|
+
meta.branchRef = branchName;
|
|
132
|
+
meta.updatedAt = new Date().toISOString();
|
|
133
|
+
await storage.put(key, Buffer.from(JSON.stringify(meta)), 'application/json');
|
|
134
|
+
}
|
|
135
|
+
return meta;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const meta: ChannelMetadata = {
|
|
140
|
+
name: channelName,
|
|
141
|
+
branchRef: branchName,
|
|
142
|
+
status: 'active',
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
};
|
|
146
|
+
await storage.put(key, Buffer.from(JSON.stringify(meta)), 'application/json');
|
|
147
|
+
return meta;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Regenerate channel pointer manifests after a channel's branch mapping changes.
|
|
152
|
+
*
|
|
153
|
+
* Scans releases/{branchRef}/ for all platform+runtimeVersion combos,
|
|
154
|
+
* finds the latest manifest for each, and copies it to the channel pointer path:
|
|
155
|
+
* {channelName}/{platform}/{runtimeVersion}/manifest.json
|
|
156
|
+
*/
|
|
157
|
+
export async function updateChannelPointers(
|
|
158
|
+
storage: StorageAdapter,
|
|
159
|
+
channelName: string,
|
|
160
|
+
branchRef: string,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const keys = await storage.list(`releases/${branchRef}/`);
|
|
163
|
+
const manifestKeys = keys.filter(k => k.endsWith('/manifest.json'));
|
|
164
|
+
|
|
165
|
+
// Group by platform+runtimeVersion
|
|
166
|
+
// Key format: releases/{branch}/{runtimeVersion}/{groupId}/{platform}/manifest.json
|
|
167
|
+
const grouped = new Map<string, string[]>();
|
|
168
|
+
for (const key of manifestKeys) {
|
|
169
|
+
const parts = key.split('/');
|
|
170
|
+
if (parts.length >= 6) {
|
|
171
|
+
const runtimeVersion = parts[2];
|
|
172
|
+
const platform = parts[4];
|
|
173
|
+
const combo = `${platform}/${runtimeVersion}`;
|
|
174
|
+
if (!grouped.has(combo)) grouped.set(combo, []);
|
|
175
|
+
grouped.get(combo)!.push(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Helper: find the latest manifest key from a list by meta.json createdAt
|
|
180
|
+
async function findLatestManifest(paths: string[]): Promise<string | undefined> {
|
|
181
|
+
let latestKey: string | undefined;
|
|
182
|
+
let latestCreatedAt = '';
|
|
183
|
+
|
|
184
|
+
for (const mKey of paths) {
|
|
185
|
+
const parts = mKey.split('/');
|
|
186
|
+
const metaKey = parts.slice(0, 4).join('/') + '/meta.json';
|
|
187
|
+
try {
|
|
188
|
+
const metaResult = await storage.get(metaKey);
|
|
189
|
+
if (metaResult) {
|
|
190
|
+
const meta = JSON.parse(metaResult.data.toString('utf8'));
|
|
191
|
+
if (meta.createdAt > latestCreatedAt) {
|
|
192
|
+
latestCreatedAt = meta.createdAt;
|
|
193
|
+
latestKey = mKey;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
if (!latestKey) latestKey = mKey;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return latestKey;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Mobile platforms: per-runtimeVersion pointers at {channel}/{platform}/{rv}/manifest.json
|
|
204
|
+
for (const [combo, paths] of grouped) {
|
|
205
|
+
const [platform] = combo.split('/');
|
|
206
|
+
if (platform === 'web') continue; // web handled separately below
|
|
207
|
+
|
|
208
|
+
const latestManifestKey = await findLatestManifest(paths);
|
|
209
|
+
if (latestManifestKey) {
|
|
210
|
+
const [, runtimeVersion] = combo.split('/');
|
|
211
|
+
const pointerKey = `${channelName}/${platform}/${runtimeVersion}/manifest.json`;
|
|
212
|
+
const manifestData = await storage.get(latestManifestKey);
|
|
213
|
+
if (manifestData) {
|
|
214
|
+
await storage.put(pointerKey, manifestData.data, 'application/json');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Web platform: single runtimeVersion-agnostic pointer at {channel}/web/manifest.json
|
|
220
|
+
// Collects ALL web manifests across ALL runtimeVersions and finds the latest.
|
|
221
|
+
const allWebManifests: string[] = [];
|
|
222
|
+
for (const [combo, paths] of grouped) {
|
|
223
|
+
const [platform] = combo.split('/');
|
|
224
|
+
if (platform === 'web') {
|
|
225
|
+
allWebManifests.push(...paths);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (allWebManifests.length > 0) {
|
|
230
|
+
const latestWebKey = await findLatestManifest(allWebManifests);
|
|
231
|
+
if (latestWebKey) {
|
|
232
|
+
const pointerKey = `${channelName}/web/manifest.json`;
|
|
233
|
+
const manifestData = await storage.get(latestWebKey);
|
|
234
|
+
if (manifestData) {
|
|
235
|
+
await storage.put(pointerKey, manifestData.data, 'application/json');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
2
|
+
import { handleManifest } from './manifest';
|
|
3
|
+
import { handleAssets } from './assets';
|
|
4
|
+
import { handlePublish } from './publish';
|
|
5
|
+
import { handlePresignWeb, handleRegisterWeb } from './publish-web';
|
|
6
|
+
import { handleListBranches, handleCreateBranch, handleDeleteBranch } from './branches';
|
|
7
|
+
import { handleListChannels, handleCreateChannel, handleEditChannel } from './channels-crud';
|
|
8
|
+
|
|
9
|
+
export { handleRegisterWeb } from './publish-web';
|
|
10
|
+
export { registerMobileRelease } from './publish';
|
|
11
|
+
export type { RegisterMobileParams, RegisterMobileResult, RegisterMobileAsset } from './publish';
|
|
12
|
+
export type { UpdatesHandlerOptions } from './types';
|
|
13
|
+
|
|
14
|
+
export function createUpdatesHandler(options: UpdatesHandlerOptions): (request: Request) => Promise<Response> {
|
|
15
|
+
const basePath = options.basePath || '';
|
|
16
|
+
|
|
17
|
+
return async (request: Request): Promise<Response> => {
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
let pathname = url.pathname;
|
|
20
|
+
|
|
21
|
+
if (basePath && pathname.startsWith(basePath)) {
|
|
22
|
+
pathname = pathname.slice(basePath.length) || '/';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (request.method === 'GET' && pathname === '/manifest') {
|
|
26
|
+
return handleManifest(request, options);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (request.method === 'GET' && pathname === '/assets') {
|
|
30
|
+
return handleAssets(request, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (request.method === 'POST' && pathname === '/publish') {
|
|
34
|
+
return handlePublish(request, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (request.method === 'POST' && pathname === '/presign-web') {
|
|
38
|
+
return handlePresignWeb(request, options);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (request.method === 'POST' && pathname === '/register-web') {
|
|
42
|
+
return handleRegisterWeb(request, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Branch CRUD
|
|
46
|
+
if (request.method === 'GET' && pathname === '/branches') {
|
|
47
|
+
return handleListBranches(request, options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (request.method === 'POST' && pathname === '/branches') {
|
|
51
|
+
return handleCreateBranch(request, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (request.method === 'DELETE' && pathname.startsWith('/branches/')) {
|
|
55
|
+
const branchName = decodeURIComponent(pathname.slice('/branches/'.length));
|
|
56
|
+
return handleDeleteBranch(request, options, branchName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Channel CRUD
|
|
60
|
+
if (request.method === 'GET' && pathname === '/channels') {
|
|
61
|
+
return handleListChannels(request, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (request.method === 'POST' && pathname === '/channels') {
|
|
65
|
+
return handleCreateChannel(request, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (request.method === 'PUT' && pathname.startsWith('/channels/')) {
|
|
69
|
+
const channelName = decodeURIComponent(pathname.slice('/channels/'.length));
|
|
70
|
+
return handleEditChannel(request, options, channelName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
74
|
+
status: 404,
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { serializeDictionary } from 'structured-headers';
|
|
2
|
+
import { eq, and, desc, sql } from 'drizzle-orm';
|
|
3
|
+
import type { UpdatesHandlerOptions } from './types';
|
|
4
|
+
import { opsItems, buildArtifacts, runs } from '../schema';
|
|
5
|
+
import { buildMultipartResponse } from './multipart';
|
|
6
|
+
import { signRSASHA256, convertSHA256HashToUUID, convertToDictionaryItemsRepresentation } from './signing';
|
|
7
|
+
import { jsonError } from './helpers';
|
|
8
|
+
|
|
9
|
+
export async function handleManifest(request: Request, options: UpdatesHandlerOptions): Promise<Response> {
|
|
10
|
+
const headers = request.headers;
|
|
11
|
+
|
|
12
|
+
const protocolVersionRaw = headers.get('expo-protocol-version');
|
|
13
|
+
const protocolVersion = parseInt(protocolVersionRaw ?? '0', 10);
|
|
14
|
+
|
|
15
|
+
const platform = headers.get('expo-platform');
|
|
16
|
+
if (platform !== 'ios' && platform !== 'android') {
|
|
17
|
+
return jsonError(400, 'Unsupported platform. Expected either ios or android.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const runtimeVersion = headers.get('expo-runtime-version');
|
|
21
|
+
if (!runtimeVersion) {
|
|
22
|
+
return jsonError(400, 'No runtimeVersion provided.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const channelName = headers.get('expo-channel-name') || options.defaultChannel || 'production';
|
|
26
|
+
|
|
27
|
+
// Try S3 flat-file manifest first (works without DB)
|
|
28
|
+
const s3Manifest = await tryS3Manifest(options, channelName, platform, runtimeVersion);
|
|
29
|
+
if (s3Manifest) {
|
|
30
|
+
return buildManifestResponse(request, s3Manifest, options, protocolVersion);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fall back to DB-based lookup (V2)
|
|
34
|
+
if (!options.db) {
|
|
35
|
+
return jsonError(404, `No update found for ${channelName}/${platform}/${runtimeVersion}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let release: any;
|
|
39
|
+
let assets: any[];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Look up channel (opsItems type='release_channel')
|
|
43
|
+
const [channel] = await options.db
|
|
44
|
+
.select()
|
|
45
|
+
.from(opsItems)
|
|
46
|
+
.where(and(
|
|
47
|
+
eq(opsItems.type, 'release_channel'),
|
|
48
|
+
eq(opsItems.name, channelName),
|
|
49
|
+
))
|
|
50
|
+
.limit(1);
|
|
51
|
+
|
|
52
|
+
if (!channel) {
|
|
53
|
+
return jsonError(404, `Channel "${channelName}" not found.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const branchRef = (channel.data as Record<string, unknown>)?.branchRef as string;
|
|
57
|
+
if (!branchRef) {
|
|
58
|
+
return jsonError(404, `Channel "${channelName}" has no branch mapping.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Look up latest release (buildArtifact type='ota') via run branchRef
|
|
62
|
+
const releaseResults = await options.db
|
|
63
|
+
.select({ release: buildArtifacts })
|
|
64
|
+
.from(buildArtifacts)
|
|
65
|
+
.innerJoin(runs, eq(buildArtifacts.runId, runs.id))
|
|
66
|
+
.where(and(
|
|
67
|
+
eq(buildArtifacts.type, 'ota'),
|
|
68
|
+
eq(buildArtifacts.runtimeVersion, runtimeVersion),
|
|
69
|
+
eq(buildArtifacts.platform, platform),
|
|
70
|
+
eq(runs.type, 'release'),
|
|
71
|
+
sql`${runs.data}->>'branchRef' = ${branchRef}`,
|
|
72
|
+
))
|
|
73
|
+
.orderBy(desc(buildArtifacts.indexedAt))
|
|
74
|
+
.limit(1);
|
|
75
|
+
|
|
76
|
+
if (!releaseResults[0]) {
|
|
77
|
+
return jsonError(404, `No update found for runtime version: ${runtimeVersion}`);
|
|
78
|
+
}
|
|
79
|
+
release = releaseResults[0].release;
|
|
80
|
+
|
|
81
|
+
// Look up assets (buildArtifacts type='ota_asset') for this release's run
|
|
82
|
+
assets = await options.db
|
|
83
|
+
.select()
|
|
84
|
+
.from(buildArtifacts)
|
|
85
|
+
.where(and(
|
|
86
|
+
eq(buildArtifacts.type, 'ota_asset'),
|
|
87
|
+
eq(buildArtifacts.runId, release.runId),
|
|
88
|
+
));
|
|
89
|
+
} catch {
|
|
90
|
+
// DB unavailable or tables don't exist — not an error in V1
|
|
91
|
+
return jsonError(404, `No update found for ${channelName}/${platform}/${runtimeVersion}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const releaseData = (release.data as Record<string, unknown>) || {};
|
|
95
|
+
|
|
96
|
+
// Handle rollback
|
|
97
|
+
if (releaseData.isRollback) {
|
|
98
|
+
if (protocolVersion === 0) {
|
|
99
|
+
return jsonError(400, 'Rollbacks not supported on protocol version 0');
|
|
100
|
+
}
|
|
101
|
+
return buildRollbackResponse(request, release, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if client already has latest
|
|
105
|
+
const currentUpdateId = headers.get('expo-current-update-id');
|
|
106
|
+
const updateId = releaseData.updateId as string;
|
|
107
|
+
const releaseUUID = convertSHA256HashToUUID(updateId);
|
|
108
|
+
if (currentUpdateId === releaseUUID && protocolVersion === 1) {
|
|
109
|
+
return buildNoUpdateAvailableResponse(request, options, protocolVersion);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build manifest from DB data
|
|
113
|
+
const launchAsset = assets.find((a: any) => (a.data as Record<string, unknown>)?.isLaunchAsset);
|
|
114
|
+
const regularAssets = assets.filter((a: any) => !(a.data as Record<string, unknown>)?.isLaunchAsset);
|
|
115
|
+
|
|
116
|
+
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
117
|
+
const basePath = options.basePath || '';
|
|
118
|
+
|
|
119
|
+
const manifest = {
|
|
120
|
+
id: releaseUUID,
|
|
121
|
+
createdAt: release.indexedAt.toISOString(),
|
|
122
|
+
runtimeVersion: release.runtimeVersion,
|
|
123
|
+
assets: regularAssets.map((a: any) => {
|
|
124
|
+
const assetData = (a.data as Record<string, unknown>) || {};
|
|
125
|
+
return {
|
|
126
|
+
hash: a.contentHash,
|
|
127
|
+
key: a.filename,
|
|
128
|
+
fileExtension: assetData.fileExtension,
|
|
129
|
+
contentType: a.mimeType,
|
|
130
|
+
url: `${baseUrl}${basePath}/assets?key=${a.s3Key}`,
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
launchAsset: launchAsset
|
|
134
|
+
? (() => {
|
|
135
|
+
const launchData = (launchAsset.data as Record<string, unknown>) || {};
|
|
136
|
+
return {
|
|
137
|
+
hash: launchAsset.contentHash,
|
|
138
|
+
key: launchAsset.filename,
|
|
139
|
+
fileExtension: launchData.fileExtension,
|
|
140
|
+
contentType: launchAsset.mimeType,
|
|
141
|
+
url: `${baseUrl}${basePath}/assets?key=${launchAsset.s3Key}`,
|
|
142
|
+
};
|
|
143
|
+
})()
|
|
144
|
+
: undefined,
|
|
145
|
+
metadata: {},
|
|
146
|
+
extra: {
|
|
147
|
+
expoClient: releaseData.expoConfig || undefined,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return buildManifestResponse(request, manifest, options, protocolVersion);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Try reading a pre-generated manifest from S3.
|
|
156
|
+
* Path convention: {channel}/{platform}/{runtimeVersion}/manifest.json
|
|
157
|
+
*/
|
|
158
|
+
async function tryS3Manifest(
|
|
159
|
+
options: UpdatesHandlerOptions,
|
|
160
|
+
channel: string,
|
|
161
|
+
platform: string,
|
|
162
|
+
runtimeVersion: string,
|
|
163
|
+
): Promise<any | null> {
|
|
164
|
+
const key = `${channel}/${platform}/${runtimeVersion}/manifest.json`;
|
|
165
|
+
try {
|
|
166
|
+
const result = await options.storage.get(key);
|
|
167
|
+
if (!result) return null;
|
|
168
|
+
return JSON.parse(result.data.toString('utf8'));
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the multipart manifest response with optional code signing.
|
|
176
|
+
*/
|
|
177
|
+
function buildManifestResponse(
|
|
178
|
+
request: Request,
|
|
179
|
+
manifest: any,
|
|
180
|
+
options: UpdatesHandlerOptions,
|
|
181
|
+
protocolVersion: number,
|
|
182
|
+
): Response {
|
|
183
|
+
// Code signing
|
|
184
|
+
let signature: string | null = null;
|
|
185
|
+
const expectSignature = request.headers.get('expo-expect-signature');
|
|
186
|
+
if (expectSignature && options.privateKey) {
|
|
187
|
+
const manifestString = JSON.stringify(manifest);
|
|
188
|
+
const hashSignature = signRSASHA256(manifestString, options.privateKey);
|
|
189
|
+
const dictionary = convertToDictionaryItemsRepresentation({ sig: hashSignature, keyid: 'main' });
|
|
190
|
+
signature = serializeDictionary(dictionary);
|
|
191
|
+
} else if (expectSignature && !options.privateKey) {
|
|
192
|
+
return jsonError(400, 'Code signing requested but no key supplied.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const parts = [
|
|
196
|
+
{
|
|
197
|
+
name: 'manifest',
|
|
198
|
+
body: JSON.stringify(manifest),
|
|
199
|
+
headers: signature ? { 'expo-signature': signature } : undefined,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'extensions',
|
|
203
|
+
body: JSON.stringify({ assetRequestHeaders: {} }),
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
return buildMultipartResponse(parts, {
|
|
208
|
+
'expo-protocol-version': String(protocolVersion),
|
|
209
|
+
'expo-sfv-version': '0',
|
|
210
|
+
'cache-control': 'private, max-age=0',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function buildRollbackResponse(request: Request, release: any, options: UpdatesHandlerOptions): Promise<Response> {
|
|
215
|
+
const directive = {
|
|
216
|
+
type: 'rollBackToEmbedded',
|
|
217
|
+
parameters: {
|
|
218
|
+
commitTime: release.indexedAt.toISOString(),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
let signature: string | null = null;
|
|
223
|
+
const expectSignature = request.headers.get('expo-expect-signature');
|
|
224
|
+
if (expectSignature && options.privateKey) {
|
|
225
|
+
const directiveString = JSON.stringify(directive);
|
|
226
|
+
const hashSignature = signRSASHA256(directiveString, options.privateKey);
|
|
227
|
+
const dictionary = convertToDictionaryItemsRepresentation({ sig: hashSignature, keyid: 'main' });
|
|
228
|
+
signature = serializeDictionary(dictionary);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parts = [
|
|
232
|
+
{
|
|
233
|
+
name: 'directive',
|
|
234
|
+
body: JSON.stringify(directive),
|
|
235
|
+
headers: signature ? { 'expo-signature': signature } : undefined,
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
return buildMultipartResponse(parts, {
|
|
240
|
+
'expo-protocol-version': '1',
|
|
241
|
+
'expo-sfv-version': '0',
|
|
242
|
+
'cache-control': 'private, max-age=0',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function buildNoUpdateAvailableResponse(request: Request, options: UpdatesHandlerOptions, protocolVersion: number): Promise<Response> {
|
|
247
|
+
if (protocolVersion === 0) {
|
|
248
|
+
return jsonError(400, 'NoUpdateAvailable directive not available in protocol version 0');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const directive = { type: 'noUpdateAvailable' };
|
|
252
|
+
|
|
253
|
+
let signature: string | null = null;
|
|
254
|
+
const expectSignature = request.headers.get('expo-expect-signature');
|
|
255
|
+
if (expectSignature && options.privateKey) {
|
|
256
|
+
const directiveString = JSON.stringify(directive);
|
|
257
|
+
const hashSignature = signRSASHA256(directiveString, options.privateKey);
|
|
258
|
+
const dictionary = convertToDictionaryItemsRepresentation({ sig: hashSignature, keyid: 'main' });
|
|
259
|
+
signature = serializeDictionary(dictionary);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const parts = [
|
|
263
|
+
{
|
|
264
|
+
name: 'directive',
|
|
265
|
+
body: JSON.stringify(directive),
|
|
266
|
+
headers: signature ? { 'expo-signature': signature } : undefined,
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
return buildMultipartResponse(parts, {
|
|
271
|
+
'expo-protocol-version': '1',
|
|
272
|
+
'expo-sfv-version': '0',
|
|
273
|
+
'cache-control': 'private, max-age=0',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
export interface MultipartPart {
|
|
4
|
+
name: string;
|
|
5
|
+
body: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildMultipartResponse(parts: MultipartPart[], responseHeaders?: Record<string, string>): Response {
|
|
10
|
+
const boundary = `boundary-${crypto.randomBytes(16).toString('hex')}`;
|
|
11
|
+
const chunks: string[] = [];
|
|
12
|
+
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
chunks.push(`--${boundary}\r\n`);
|
|
15
|
+
chunks.push(`Content-Disposition: form-data; name="${part.name}"\r\n`);
|
|
16
|
+
chunks.push(`Content-Type: application/json; charset=utf-8\r\n`);
|
|
17
|
+
if (part.headers) {
|
|
18
|
+
for (const [key, value] of Object.entries(part.headers)) {
|
|
19
|
+
chunks.push(`${key}: ${value}\r\n`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
chunks.push(`\r\n`);
|
|
23
|
+
chunks.push(part.body);
|
|
24
|
+
chunks.push(`\r\n`);
|
|
25
|
+
}
|
|
26
|
+
chunks.push(`--${boundary}--\r\n`);
|
|
27
|
+
|
|
28
|
+
const body = chunks.join('');
|
|
29
|
+
|
|
30
|
+
return new Response(body, {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: {
|
|
33
|
+
'content-type': `multipart/mixed; boundary=${boundary}`,
|
|
34
|
+
...responseHeaders,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseMultipartResponse(body: string, boundary: string): MultipartPart[] {
|
|
40
|
+
const parts: MultipartPart[] = [];
|
|
41
|
+
const segments = body.split(`--${boundary}`);
|
|
42
|
+
|
|
43
|
+
for (const segment of segments) {
|
|
44
|
+
if (segment.trim() === '' || segment.trim() === '--') continue;
|
|
45
|
+
|
|
46
|
+
const headerBodySplit = segment.indexOf('\r\n\r\n');
|
|
47
|
+
if (headerBodySplit === -1) continue;
|
|
48
|
+
|
|
49
|
+
const headerSection = segment.slice(0, headerBodySplit);
|
|
50
|
+
const bodySection = segment.slice(headerBodySplit + 4).replace(/\r\n$/, '');
|
|
51
|
+
|
|
52
|
+
const headers: Record<string, string> = {};
|
|
53
|
+
let name = '';
|
|
54
|
+
|
|
55
|
+
for (const line of headerSection.split('\r\n')) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
const colonIdx = line.indexOf(':');
|
|
58
|
+
if (colonIdx === -1) continue;
|
|
59
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
60
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
61
|
+
|
|
62
|
+
if (key === 'content-disposition') {
|
|
63
|
+
const nameMatch = value.match(/name="([^"]+)"/);
|
|
64
|
+
if (nameMatch) name = nameMatch[1];
|
|
65
|
+
} else if (key !== 'content-type') {
|
|
66
|
+
headers[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
parts.push({ name, body: bodySection, headers: Object.keys(headers).length > 0 ? headers : undefined });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parts;
|
|
74
|
+
}
|