Skip to content
Open
79 changes: 41 additions & 38 deletions README.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ test('rejects a tampered bundle (signature verification)', async () => {
await app.close();
});

test('syncs a manifest (delta) bundle over the built-in bundle', async () => {
await mockServer.setLatest('4.0.0-manifest');
const { app, page } = await launchExample();
await page.getByTestId('sync').click();
await expect(page.getByTestId('next-bundle')).toHaveText('4.0.0-manifest');
await page.getByTestId('reload').click();
await expect(page.getByTestId('current-bundle')).toHaveText('4.0.0-manifest');
await expect(page.getByTestId('marker')).toHaveText('2.0.0');
await expect(page.getByTestId('ready-state')).toContainText(
'rollback: false',
);
await app.close();
});

test('simple mode: syncs and reloads via getCurrentBundlePath()', async () => {
await mockServer.setLatest('2.0.0');
const { app, page } = await launchExample({ servingMode: 'simple' });
Expand Down
2 changes: 1 addition & 1 deletion e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export async function readState(userDataDirectory) {
try {
return JSON.parse(
await readFile(
join(userDataDirectory, 'live-update', 'state.json'),
join(userDataDirectory, 'capawesome-live-update', 'state.json'),
'utf8',
),
);
Expand Down
159 changes: 148 additions & 11 deletions example/scripts/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,106 @@
*
* Speaks the Live Update protocol:
* - GET /v1/apps/{appId}/bundles/latest -> latest bundle JSON or 404
* - GET /v1/apps/{appId}/channels -> list of channels (or 401 when
* CHANNELS_DISABLED is set)
* - GET /download/{file} -> zip bytes with X-Checksum
* and X-Signature headers
* - GET /manifest/{bundleId}?href=<href> -> the manifest JSON (delta)
* or a single file with its
* X-Checksum / X-Signature headers
* - POST /__control -> {"latest": "<bundleId>" | null}
* switches the offered bundle
*
* The offered bundle can also be set via the LATEST env variable.
*/
import { readFile } from 'node:fs/promises';
import { createHash, createSign } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const exampleDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
const bundlesDirectory = join(exampleDirectory, 'dist', 'bundles');
const keysDirectory = join(exampleDirectory, 'dist', 'keys');
const port = Number(process.env.MOCK_SERVER_PORT ?? 4100);

const MANIFEST_FILE_NAME = 'capawesome-live-update-manifest.json';

const bundles = JSON.parse(
await readFile(join(bundlesDirectory, 'index.json'), 'utf8'),
);
let latestBundleId = process.env.LATEST ?? null;

const privateKeyPath = join(keysDirectory, 'private.pem');
const privateKeyPem = existsSync(privateKeyPath)
? await readFile(privateKeyPath, 'utf8')
: null;

// Manifest (delta) bundles are served directly from a source directory
// of web assets; the manifest itself is generated on the fly.
const manifestBundles = {
'4.0.0-manifest': join(exampleDirectory, 'dist', 'bundle-2.0.0'),
};

const channels = [
{ id: 'a1b2c3d4-0000-0000-0000-000000000001', name: 'production' },
{ id: 'a1b2c3d4-0000-0000-0000-000000000002', name: 'beta' },
{ id: 'a1b2c3d4-0000-0000-0000-000000000003', name: 'canary' },
];

function checksum(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}

function sign(bytes) {
if (!privateKeyPem) {
return null;
}
const signer = createSign('RSA-SHA256');
signer.update(bytes);
return signer.sign(privateKeyPem).toString('base64');
}

async function listFiles(directory) {
const files = [];
const walk = async current => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const entryPath = join(current, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
} else if (entry.isFile()) {
files.push({
absolutePath: entryPath,
href: relative(directory, entryPath).split('\\').join('/'),
});
}
}
};
await walk(directory);
return files;
}

async function buildManifest(directory) {
const files = await listFiles(directory);
return Promise.all(
files.map(async file => {
const bytes = await readFile(file.absolutePath);
return {
checksum: checksum(bytes),
href: file.href,
sizeInBytes: bytes.length,
};
}),
);
}

function sendJson(response, status, payload) {
response.statusCode = status;
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(payload));
}

const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://localhost:${port}`);
console.log(`[mock-server] ${request.method} ${url.pathname}${url.search}`);
Expand All @@ -36,25 +115,83 @@ const server = createServer(async (request, response) => {
response.end(JSON.stringify({ latest: latestBundleId }));
return;
}
if (
request.method === 'GET' &&
/^\/v1\/apps\/[^/]+\/channels$/.test(url.pathname)
) {
if (process.env.CHANNELS_DISABLED) {
response.statusCode = 401;
response.end(
JSON.stringify({
message:
'Unauthorized. Channel Discovery may not be enabled for this app.',
}),
);
return;
}
const limit = Number(url.searchParams.get('limit') ?? 50);
const offset = Number(url.searchParams.get('offset') ?? 0);
const query = url.searchParams.get('query');
const filtered = channels.filter(channel =>
query ? channel.name.includes(query) : true,
);
sendJson(response, 200, filtered.slice(offset, offset + limit));
return;
}
if (
request.method === 'GET' &&
/^\/v1\/apps\/[^/]+\/bundles\/latest$/.test(url.pathname)
) {
if (latestBundleId && manifestBundles[latestBundleId]) {
sendJson(response, 200, {
artifactType: 'manifest',
bundleId: latestBundleId,
url: `http://localhost:${port}/manifest/${latestBundleId}`,
});
return;
}
const bundle =
latestBundleId === null ? undefined : bundles[latestBundleId];
if (!bundle) {
response.statusCode = 404;
response.end(JSON.stringify({ message: 'No bundle available.' }));
sendJson(response, 404, { message: 'No bundle available.' });
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
artifactType: 'zip',
bundleId: latestBundleId,
url: `http://localhost:${port}/download/${bundle.file}`,
}),
sendJson(response, 200, {
artifactType: 'zip',
bundleId: latestBundleId,
url: `http://localhost:${port}/download/${bundle.file}`,
});
return;
}
if (request.method === 'GET' && url.pathname.startsWith('/manifest/')) {
const bundleId = decodeURIComponent(
url.pathname.slice('/manifest/'.length),
);
const sourceDirectory = manifestBundles[bundleId];
if (!sourceDirectory || !existsSync(sourceDirectory)) {
response.statusCode = 404;
response.end('Not found');
return;
}
const href = url.searchParams.get('href');
if (href === MANIFEST_FILE_NAME) {
sendJson(response, 200, await buildManifest(sourceDirectory));
return;
}
const files = await listFiles(sourceDirectory);
const file = files.find(entry => entry.href === href);
if (!file) {
response.statusCode = 404;
response.end('Not found');
return;
}
const bytes = await readFile(file.absolutePath);
response.setHeader('X-Checksum', checksum(bytes));
const signature = sign(bytes);
if (signature) {
response.setHeader('X-Signature', signature);
}
response.end(bytes);
return;
}
if (request.method === 'GET' && url.pathname.startsWith('/download/')) {
Expand Down
94 changes: 92 additions & 2 deletions src/engine/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,24 @@ export interface FetchLatestBundleRequest {
deviceId: string;
osVersion: string;
platform: string;
pluginVersion: string;
runtime: string | null;
sdkVersion: string;
}

export interface FetchChannelsRequest {
appId: string;
deviceId: string;
limit: number;
offset: number;
query: string | null;
}

/**
* A single channel returned by the Capawesome Cloud channels endpoint.
*/
export interface GetChannelsResponseItem {
id: string;
name: string;
}

export interface CloudApiClientOptions {
Expand Down Expand Up @@ -89,7 +105,7 @@ export class CloudApiClient {
this.appendQueryParameter(url, 'deviceId', request.deviceId);
this.appendQueryParameter(url, 'osVersion', request.osVersion);
this.appendQueryParameter(url, 'platform', request.platform);
this.appendQueryParameter(url, 'pluginVersion', request.sdkVersion);
this.appendQueryParameter(url, 'pluginVersion', request.pluginVersion);
this.appendQueryParameter(url, 'runtime', request.runtime);
let response: Response;
try {
Expand Down Expand Up @@ -121,6 +137,63 @@ export class CloudApiClient {
return this.parseLatestBundleResponse(json);
}

/**
* Fetch the available channels for the app.
*
* Throws `ChannelDiscoveryNotEnabled` on HTTP 401 (public channels
* not enabled), mirroring the `@capawesome/capacitor-live-update`
* plugin behavior.
*/
public async getChannels(
request: FetchChannelsRequest,
): Promise<GetChannelsResponseItem[]> {
const url = new URL(
`${this.getBaseUrl()}/v1/apps/${encodeURIComponent(request.appId)}/channels`,
);
this.appendQueryParameter(url, 'limit', String(request.limit));
this.appendQueryParameter(url, 'offset', String(request.offset));
this.appendQueryParameter(url, 'query', request.query);
let response: Response;
try {
response = await fetch(url, {
headers: {
'X-Capawesome-Device-Id': request.deviceId,
},
signal: AbortSignal.timeout(this.options.httpTimeout),
});
} catch (error) {
if (isTimeoutError(error)) {
throw new LiveUpdateError(ErrorCode.HttpTimeout, 'Request timed out.');
}
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
if (response.status === 401) {
throw new LiveUpdateError(
ErrorCode.ChannelDiscoveryNotEnabled,
'Unauthorized. Channel Discovery may not be enabled for this app.',
);
}
if (!response.ok) {
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
let json: unknown;
try {
json = await response.json();
} catch {
throw new LiveUpdateError(
ErrorCode.Unknown,
'An unknown error has occurred.',
);
}
return this.parseChannelsResponse(json);
}

private appendQueryParameter(
url: URL,
name: string,
Expand All @@ -131,6 +204,23 @@ export class CloudApiClient {
}
}

private parseChannelsResponse(json: unknown): GetChannelsResponseItem[] {
if (!Array.isArray(json)) {
return [];
}
const channels: GetChannelsResponseItem[] = [];
for (const entry of json) {
if (typeof entry !== 'object' || entry === null) {
continue;
}
const record = entry as Record<string, unknown>;
if (typeof record.id === 'string' && typeof record.name === 'string') {
channels.push({ id: record.id, name: record.name });
}
}
return channels;
}

private parseLatestBundleResponse(
json: unknown,
): GetLatestBundleResponse | null {
Expand Down
Loading
Loading