@syntholoc/ota 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/index.js +2 -0
- package/dist/ota/src/index.d.ts +53 -0
- package/dist/ota/src/server.d.ts +4 -0
- package/dist/ota/src/storage.d.ts +38 -0
- package/dist/server.js +178 -0
- package/dist/shared/src/ota/index.d.ts +93 -0
- package/dist/src-DmqzlY0J.js +688 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syntholoc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @syntholoc/ota
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small ESM TypeScript client for Syntholoc OTA v1. It verifies manifests/catalogs with
|
|
4
|
+
SHA-256, keeps bundled resources available offline, stages complete updates, and atomically
|
|
5
|
+
activates or rolls back translation overlays. IndexedDB, memory and server file adapters
|
|
6
|
+
are included. There are no runtime GraphQL, Prisma or framework dependencies.
|
|
7
|
+
|
|
8
|
+
See [integration](../../docs/OTA_INTEGRATION.md), [protocol](../../docs/OTA_PROTOCOL.md)
|
|
9
|
+
and [operations](../../docs/OTA_OPERATIONS.md). Native SDKs live separately in `sdks/`.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createOtaClient, createIndexedDbStorage } from '@syntholoc/ota';
|
|
13
|
+
const ota = createOtaClient({
|
|
14
|
+
baseUrl: 'https://translations.example.com',
|
|
15
|
+
distributionId: 'dst_example',
|
|
16
|
+
channel: 'production',
|
|
17
|
+
catalogVersion: 'app-v1',
|
|
18
|
+
localeKey: 'ru',
|
|
19
|
+
bundles: ['app'],
|
|
20
|
+
storage: createIndexedDbStorage(),
|
|
21
|
+
fallbackResources: { ru: { app: { hello: 'Привет' } } },
|
|
22
|
+
});
|
|
23
|
+
await ota.loadCached();
|
|
24
|
+
const update = await ota.checkForUpdates();
|
|
25
|
+
if (update.status === 'available') await ota.activate(update.releaseId);
|
|
26
|
+
console.log(ota.getResources());
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`forceRefresh` skips freshness, never backoff/security. `resetFailureState` resumes after
|
|
30
|
+
credential changes/explicit retirement override. Create a new scoped client to change language
|
|
31
|
+
or channel. The package does not poll on a background timer; scheduling belongs to the host.
|
|
32
|
+
|
|
33
|
+
Build: `bun run build`. Test: `bun run test`. Pack with
|
|
34
|
+
`bun scripts/publish-package.ts packages/ota --dry-run` from the repository root.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type JsonCatalog } from '../../shared/src/ota/index.js';
|
|
2
|
+
import { type OtaStorage } from './storage.js';
|
|
3
|
+
export { createMemoryStorage, createIndexedDbStorage } from './storage.js';
|
|
4
|
+
export type { OtaStorage, OtaCacheState, CachedSet } from './storage.js';
|
|
5
|
+
export type { JsonCatalog, OtaChannelManifest, OtaReleaseManifest } from '../../shared/src/ota/index.js';
|
|
6
|
+
export type Resources = Record<string, Record<string, JsonCatalog>>;
|
|
7
|
+
export interface OtaClientOptions {
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
distributionId: string;
|
|
10
|
+
channel: string;
|
|
11
|
+
catalogVersion: string;
|
|
12
|
+
localeKey: string;
|
|
13
|
+
bundles: string[];
|
|
14
|
+
fallbackLocaleKeys?: string[];
|
|
15
|
+
fallbackResources?: Resources;
|
|
16
|
+
storage?: OtaStorage;
|
|
17
|
+
credentialProvider?: () => string | Promise<string>;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
checkIntervalSeconds?: number;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
onUpdateAvailable?: (releaseId: string) => void;
|
|
22
|
+
onActivated?: (releaseId: string) => void;
|
|
23
|
+
onError?: (error: Error) => void;
|
|
24
|
+
}
|
|
25
|
+
export type CheckResult = {
|
|
26
|
+
status: 'available';
|
|
27
|
+
releaseId: string;
|
|
28
|
+
} | {
|
|
29
|
+
status: 'unchanged' | 'skipped' | 'retired' | 'error';
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
export declare class OtaError extends Error {
|
|
33
|
+
readonly status?: number | undefined;
|
|
34
|
+
readonly retryAfterMs: number;
|
|
35
|
+
constructor(message: string, status?: number | undefined, retryAfterMs?: number);
|
|
36
|
+
}
|
|
37
|
+
export declare function digest(bytes: Uint8Array): Promise<string>;
|
|
38
|
+
export declare function createOtaClient(options: OtaClientOptions): {
|
|
39
|
+
loadCached: () => Promise<{
|
|
40
|
+
releaseId?: undefined;
|
|
41
|
+
} | {
|
|
42
|
+
releaseId: string;
|
|
43
|
+
}>;
|
|
44
|
+
checkForUpdates: (input?: {
|
|
45
|
+
forceRefresh?: boolean;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
}) => Promise<CheckResult>;
|
|
48
|
+
activate: (releaseId: string) => Promise<void>;
|
|
49
|
+
getResources: () => Record<string, JsonCatalog>;
|
|
50
|
+
readonly activeReleaseId: string | undefined;
|
|
51
|
+
resetFailureState(): Promise<void>;
|
|
52
|
+
clearCache(): Promise<void>;
|
|
53
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OtaAsset, OtaChannelManifest } from '../../shared/src/ota/index.js';
|
|
2
|
+
export interface CachedSet {
|
|
3
|
+
releaseId: string;
|
|
4
|
+
generation: number;
|
|
5
|
+
assets: {
|
|
6
|
+
localeKey: string;
|
|
7
|
+
asset: OtaAsset;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export interface OtaCacheState {
|
|
11
|
+
revision: number;
|
|
12
|
+
cacheEpoch?: number;
|
|
13
|
+
lastUsedAt?: number;
|
|
14
|
+
active?: CachedSet;
|
|
15
|
+
previous?: CachedSet;
|
|
16
|
+
candidate?: CachedSet;
|
|
17
|
+
channel?: OtaChannelManifest;
|
|
18
|
+
etag?: string;
|
|
19
|
+
highestGeneration?: number;
|
|
20
|
+
lastChecked?: number;
|
|
21
|
+
nextCheck?: number;
|
|
22
|
+
nextAttempt?: number;
|
|
23
|
+
retryDelayMs?: number;
|
|
24
|
+
failures?: number;
|
|
25
|
+
stopped?: 'auth' | 'retired';
|
|
26
|
+
}
|
|
27
|
+
/** commit is an atomic compare-and-set; blobs are immutable and shared by hash. */
|
|
28
|
+
export interface OtaStorage {
|
|
29
|
+
read(scope: string): Promise<OtaCacheState>;
|
|
30
|
+
commit(scope: string, state: OtaCacheState, expectedRevision: number): Promise<boolean>;
|
|
31
|
+
readBlob(hash: string): Promise<Uint8Array | undefined>;
|
|
32
|
+
writeBlob(hash: string, bytes: Uint8Array): Promise<void>;
|
|
33
|
+
clear(scope: string): Promise<void>;
|
|
34
|
+
collect?(scope: string, maxScopes?: number): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare function createMemoryStorage(): OtaStorage;
|
|
37
|
+
/** IDB owns atomic metadata/pointer commits across tabs. Catalogs never use localStorage. */
|
|
38
|
+
export declare function createIndexedDbStorage(databaseName?: string): OtaStorage;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { a as createMemoryStorage, i as createIndexedDbStorage, n as createOtaClient, r as digest, t as OtaError } from "./src-DmqzlY0J.js";
|
|
2
|
+
import { mkdir, open, readdir, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { lock } from "proper-lockfile";
|
|
6
|
+
//#region src/server.ts
|
|
7
|
+
/** One app-private directory. Atomic rename + a shared directory lock protect server processes. */
|
|
8
|
+
function createFileStorage(directory) {
|
|
9
|
+
const path = (kind, key) => join(directory, `${kind}-${createHash("sha256").update(key).digest("hex")}`);
|
|
10
|
+
function metadata(bytes) {
|
|
11
|
+
const state = JSON.parse(bytes);
|
|
12
|
+
if (!state || !Number.isSafeInteger(state.revision) || state.revision < 0) throw new SyntaxError("OTA_CORRUPT_CACHE");
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
async function bounded(file, maximum) {
|
|
16
|
+
const handle = await open(file, "r");
|
|
17
|
+
try {
|
|
18
|
+
if ((await handle.stat()).size > maximum) return void 0;
|
|
19
|
+
return await handle.readFile();
|
|
20
|
+
} finally {
|
|
21
|
+
await handle.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function readMetadata(file) {
|
|
25
|
+
const bytes = await bounded(file, 16777216);
|
|
26
|
+
if (!bytes) throw new SyntaxError("OTA_CORRUPT_CACHE");
|
|
27
|
+
return metadata(bytes.toString("utf8"));
|
|
28
|
+
}
|
|
29
|
+
async function read(scope) {
|
|
30
|
+
try {
|
|
31
|
+
return await readMetadata(path("scope", scope));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e.code === "ENOENT") return { revision: 0 };
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function atomic(file, bytes) {
|
|
38
|
+
await mkdir(directory, {
|
|
39
|
+
recursive: true,
|
|
40
|
+
mode: 448
|
|
41
|
+
});
|
|
42
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(temporary, bytes, { mode: 384 });
|
|
45
|
+
await rename(temporary, file);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(temporary, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function locked(fn) {
|
|
51
|
+
await mkdir(directory, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
mode: 448
|
|
54
|
+
});
|
|
55
|
+
let compromised = false;
|
|
56
|
+
const release = await lock(directory, {
|
|
57
|
+
lockfilePath: join(directory, ".ota.lock"),
|
|
58
|
+
stale: 1e4,
|
|
59
|
+
update: 3e3,
|
|
60
|
+
retries: {
|
|
61
|
+
retries: 100,
|
|
62
|
+
factor: 1,
|
|
63
|
+
minTimeout: 100,
|
|
64
|
+
maxTimeout: 100
|
|
65
|
+
},
|
|
66
|
+
onCompromised: () => {
|
|
67
|
+
compromised = true;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
const result = await fn();
|
|
72
|
+
if (compromised) throw new Error("OTA_CACHE_LOCK_LOST");
|
|
73
|
+
return result;
|
|
74
|
+
} finally {
|
|
75
|
+
if (!compromised) await release();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
read,
|
|
80
|
+
async commit(scope, state, revision) {
|
|
81
|
+
return locked(async () => {
|
|
82
|
+
if ((await read(scope)).revision !== revision) return false;
|
|
83
|
+
await atomic(path("scope", scope), JSON.stringify({
|
|
84
|
+
...state,
|
|
85
|
+
revision: revision + 1
|
|
86
|
+
}));
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
async readBlob(hash) {
|
|
91
|
+
try {
|
|
92
|
+
return await locked(async () => {
|
|
93
|
+
const file = path("blob", hash);
|
|
94
|
+
const bytes = await bounded(file, 1e7);
|
|
95
|
+
if (!bytes) return void 0;
|
|
96
|
+
const now = /* @__PURE__ */ new Date();
|
|
97
|
+
await utimes(file, now, now);
|
|
98
|
+
return bytes;
|
|
99
|
+
});
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e.code === "ENOENT") return void 0;
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
async writeBlob(hash, bytes) {
|
|
106
|
+
await locked(() => atomic(path("blob", hash), bytes));
|
|
107
|
+
},
|
|
108
|
+
async collect(scope, maxScopes = 8) {
|
|
109
|
+
await locked(async () => {
|
|
110
|
+
const names = await readdir(directory);
|
|
111
|
+
const entries = [];
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
if (!/^scope-[a-f0-9]{64}$/.test(name)) continue;
|
|
114
|
+
try {
|
|
115
|
+
entries.push({
|
|
116
|
+
file: join(directory, name),
|
|
117
|
+
state: await readMetadata(join(directory, name))
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
entries.sort((a, b) => (b.state.lastUsedAt ?? 0) - (a.state.lastUsedAt ?? 0));
|
|
124
|
+
const cutoff = Date.now() - 864e5;
|
|
125
|
+
const references = /* @__PURE__ */ new Set();
|
|
126
|
+
for (const [index, entry] of entries.entries()) {
|
|
127
|
+
const s = entry.state;
|
|
128
|
+
if (entry.file !== path("scope", scope) && index >= maxScopes && (s.lastUsedAt ?? 0) < cutoff) {
|
|
129
|
+
if (s.active || s.previous || s.candidate || s.channel || s.etag) await atomic(entry.file, JSON.stringify({
|
|
130
|
+
...s,
|
|
131
|
+
active: void 0,
|
|
132
|
+
previous: void 0,
|
|
133
|
+
candidate: void 0,
|
|
134
|
+
channel: void 0,
|
|
135
|
+
etag: void 0,
|
|
136
|
+
revision: s.revision + 1,
|
|
137
|
+
cacheEpoch: (s.cacheEpoch ?? 0) + 1
|
|
138
|
+
}));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const set of [
|
|
142
|
+
s.active,
|
|
143
|
+
s.previous,
|
|
144
|
+
s.candidate
|
|
145
|
+
]) for (const item of Array.isArray(set?.assets) ? set.assets : []) if (typeof item?.asset?.sha256 === "string") references.add(path("blob", item.asset.sha256));
|
|
146
|
+
}
|
|
147
|
+
for (const name of names) {
|
|
148
|
+
const file = join(directory, name);
|
|
149
|
+
const blob = /^blob-[a-f0-9]{64}$/.test(name);
|
|
150
|
+
const temporary = name.endsWith(".tmp");
|
|
151
|
+
if (!blob && !temporary) continue;
|
|
152
|
+
if (blob && references.has(file)) continue;
|
|
153
|
+
if ((await stat(file)).mtimeMs < cutoff) await rm(file, { force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
async clear(scope) {
|
|
158
|
+
await locked(async () => {
|
|
159
|
+
let previous;
|
|
160
|
+
try {
|
|
161
|
+
previous = await read(scope);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
164
|
+
previous = {
|
|
165
|
+
revision: Date.now(),
|
|
166
|
+
cacheEpoch: Date.now()
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
await atomic(path("scope", scope), JSON.stringify({
|
|
170
|
+
revision: previous.revision + 1,
|
|
171
|
+
cacheEpoch: (previous.cacheEpoch ?? 0) + 1
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
export { OtaError, createFileStorage, createIndexedDbStorage, createMemoryStorage, createOtaClient, digest };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** OTA wire v1. This module has no framework, Node or server dependencies. */
|
|
2
|
+
export declare const OTA_PROTOCOL_VERSION = 1;
|
|
3
|
+
export declare const OTA_FORMATS: readonly ['JSON_FLAT', 'JSON_NESTED', 'ANDROID_XML', 'IOS_STRINGS', 'IOS_STRINGSDICT', 'IOS_XCSTRINGS'];
|
|
4
|
+
export declare const OTA_PLATFORM_FORMATS: Record<string, readonly string[]>;
|
|
5
|
+
export type OtaFormat = (typeof OTA_FORMATS)[number];
|
|
6
|
+
export type JsonCatalog = {
|
|
7
|
+
[key: string]: string | JsonCatalog;
|
|
8
|
+
};
|
|
9
|
+
export interface OtaAsset {
|
|
10
|
+
id: string;
|
|
11
|
+
bundle: string;
|
|
12
|
+
namespace: string;
|
|
13
|
+
format: OtaFormat;
|
|
14
|
+
path: string;
|
|
15
|
+
sha256: string;
|
|
16
|
+
bytes: number;
|
|
17
|
+
contentType: string;
|
|
18
|
+
}
|
|
19
|
+
export interface OtaReleaseManifest {
|
|
20
|
+
protocolVersion: 1;
|
|
21
|
+
distributionId: string;
|
|
22
|
+
releaseId: string;
|
|
23
|
+
catalogVersion: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
sourceDigest: string;
|
|
26
|
+
requiredBundles: string[];
|
|
27
|
+
locales: {
|
|
28
|
+
localeKey: string;
|
|
29
|
+
languageTag: string;
|
|
30
|
+
assets: OtaAsset[];
|
|
31
|
+
}[];
|
|
32
|
+
}
|
|
33
|
+
export interface OtaChannelManifest {
|
|
34
|
+
protocolVersion: 1;
|
|
35
|
+
distributionId: string;
|
|
36
|
+
channel: string;
|
|
37
|
+
generation: number;
|
|
38
|
+
catalogVersion: string;
|
|
39
|
+
release: {
|
|
40
|
+
id: string;
|
|
41
|
+
manifestPath: string;
|
|
42
|
+
sha256: string;
|
|
43
|
+
bytes: number;
|
|
44
|
+
};
|
|
45
|
+
recommendedCheckIntervalSeconds: number;
|
|
46
|
+
}
|
|
47
|
+
export interface OtaLocaleMapping {
|
|
48
|
+
localeId: string | 'source';
|
|
49
|
+
localeKey: string;
|
|
50
|
+
languageTag: string;
|
|
51
|
+
}
|
|
52
|
+
export interface OtaBundleConfig {
|
|
53
|
+
name: string;
|
|
54
|
+
namespace: string;
|
|
55
|
+
format: OtaFormat;
|
|
56
|
+
fileSelectors: string[];
|
|
57
|
+
includeUnassigned: boolean;
|
|
58
|
+
stripKeyPrefix: boolean;
|
|
59
|
+
required: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface OtaBaselineEntry {
|
|
62
|
+
bundle: string;
|
|
63
|
+
key: string;
|
|
64
|
+
sourceText: string;
|
|
65
|
+
}
|
|
66
|
+
export interface OtaConfig {
|
|
67
|
+
rendererVersion: 1;
|
|
68
|
+
bundles: OtaBundleConfig[];
|
|
69
|
+
locales: OtaLocaleMapping[];
|
|
70
|
+
onlyApproved: boolean;
|
|
71
|
+
missingPolicy: 'OMIT' | 'SOURCE' | 'FAIL';
|
|
72
|
+
sortKeysAlphabetically: boolean;
|
|
73
|
+
baseline: OtaBaselineEntry[];
|
|
74
|
+
}
|
|
75
|
+
export declare const OTA_LIMITS: {
|
|
76
|
+
readonly manifestBytes: 2000000;
|
|
77
|
+
readonly assetBytes: 10000000;
|
|
78
|
+
readonly snapshotBytes: number;
|
|
79
|
+
readonly releaseBytes: number;
|
|
80
|
+
readonly keys: 100000;
|
|
81
|
+
readonly bundles: 50;
|
|
82
|
+
readonly locales: 100;
|
|
83
|
+
readonly assets: 5000;
|
|
84
|
+
};
|
|
85
|
+
export declare const OTA_SAFE_NAME: RegExp;
|
|
86
|
+
export declare const OTA_HASH: RegExp;
|
|
87
|
+
export declare function otaAssert(ok: unknown, message?: string): asserts ok;
|
|
88
|
+
export declare function validateChannelManifest(x: unknown): OtaChannelManifest;
|
|
89
|
+
export declare function validateReleaseManifest(x: unknown): OtaReleaseManifest;
|
|
90
|
+
export declare function validateCatalog(x: unknown, depth?: number): JsonCatalog;
|
|
91
|
+
/** Reject encoded traversal before URL normalization. Credentials never leave the delivery scope. */
|
|
92
|
+
export declare function deliveryUrl(base: string, path: string, prefix: string): URL;
|
|
93
|
+
export declare function validateOtaConfig(input: unknown): OtaConfig;
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
//#region ../shared/src/ota/index.ts
|
|
2
|
+
const OTA_FORMATS = [
|
|
3
|
+
"JSON_FLAT",
|
|
4
|
+
"JSON_NESTED",
|
|
5
|
+
"ANDROID_XML",
|
|
6
|
+
"IOS_STRINGS",
|
|
7
|
+
"IOS_STRINGSDICT",
|
|
8
|
+
"IOS_XCSTRINGS"
|
|
9
|
+
];
|
|
10
|
+
const OTA_LIMITS = {
|
|
11
|
+
manifestBytes: 2e6,
|
|
12
|
+
assetBytes: 1e7,
|
|
13
|
+
snapshotBytes: 33554432,
|
|
14
|
+
releaseBytes: 104857600,
|
|
15
|
+
keys: 1e5,
|
|
16
|
+
bundles: 50,
|
|
17
|
+
locales: 100,
|
|
18
|
+
assets: 5e3
|
|
19
|
+
};
|
|
20
|
+
const OTA_SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/;
|
|
21
|
+
const OTA_HASH = /^[a-f0-9]{64}$/;
|
|
22
|
+
const unsafe = /* @__PURE__ */ new Set([
|
|
23
|
+
"__proto__",
|
|
24
|
+
"prototype",
|
|
25
|
+
"constructor"
|
|
26
|
+
]);
|
|
27
|
+
function otaAssert(ok, message = "OTA_INVALID_MANIFEST") {
|
|
28
|
+
if (!ok) throw new Error(message);
|
|
29
|
+
}
|
|
30
|
+
function record(x) {
|
|
31
|
+
otaAssert(x && typeof x === "object" && !Array.isArray(x));
|
|
32
|
+
}
|
|
33
|
+
function str(x) {
|
|
34
|
+
otaAssert(typeof x === "string" && x.length > 0 && x.length <= 512);
|
|
35
|
+
}
|
|
36
|
+
function name(x) {
|
|
37
|
+
str(x);
|
|
38
|
+
otaAssert(OTA_SAFE_NAME.test(x));
|
|
39
|
+
}
|
|
40
|
+
function hash(x) {
|
|
41
|
+
otaAssert(typeof x === "string" && OTA_HASH.test(x));
|
|
42
|
+
}
|
|
43
|
+
function size(x, max) {
|
|
44
|
+
otaAssert(Number.isSafeInteger(x) && Number(x) >= 0 && Number(x) <= max);
|
|
45
|
+
}
|
|
46
|
+
function unique(xs) {
|
|
47
|
+
otaAssert(new Set(xs).size === xs.length);
|
|
48
|
+
}
|
|
49
|
+
function validateChannelManifest(x) {
|
|
50
|
+
record(x);
|
|
51
|
+
otaAssert(x.protocolVersion === 1);
|
|
52
|
+
name(x.distributionId);
|
|
53
|
+
name(x.channel);
|
|
54
|
+
str(x.catalogVersion);
|
|
55
|
+
size(x.generation, Number.MAX_SAFE_INTEGER);
|
|
56
|
+
size(x.recommendedCheckIntervalSeconds, 86400);
|
|
57
|
+
record(x.release);
|
|
58
|
+
name(x.release.id);
|
|
59
|
+
str(x.release.manifestPath);
|
|
60
|
+
hash(x.release.sha256);
|
|
61
|
+
size(x.release.bytes, OTA_LIMITS.manifestBytes);
|
|
62
|
+
return x;
|
|
63
|
+
}
|
|
64
|
+
function validateReleaseManifest(x) {
|
|
65
|
+
record(x);
|
|
66
|
+
otaAssert(x.protocolVersion === 1);
|
|
67
|
+
name(x.distributionId);
|
|
68
|
+
name(x.releaseId);
|
|
69
|
+
str(x.catalogVersion);
|
|
70
|
+
str(x.createdAt);
|
|
71
|
+
otaAssert(Number.isFinite(Date.parse(x.createdAt)));
|
|
72
|
+
hash(x.sourceDigest);
|
|
73
|
+
otaAssert(Array.isArray(x.requiredBundles) && x.requiredBundles.length <= OTA_LIMITS.bundles);
|
|
74
|
+
x.requiredBundles.forEach(name);
|
|
75
|
+
unique(x.requiredBundles);
|
|
76
|
+
otaAssert(Array.isArray(x.locales) && x.locales.length > 0 && x.locales.length <= OTA_LIMITS.locales);
|
|
77
|
+
let totalBytes = 0;
|
|
78
|
+
const ids = [];
|
|
79
|
+
const locales = [];
|
|
80
|
+
for (const l of x.locales) {
|
|
81
|
+
record(l);
|
|
82
|
+
str(l.localeKey);
|
|
83
|
+
str(l.languageTag);
|
|
84
|
+
try {
|
|
85
|
+
otaAssert(Intl.getCanonicalLocales(l.languageTag).length === 1);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error("OTA_INVALID_MANIFEST");
|
|
88
|
+
}
|
|
89
|
+
locales.push(l.localeKey);
|
|
90
|
+
otaAssert(Array.isArray(l.assets) && l.assets.length <= OTA_LIMITS.bundles);
|
|
91
|
+
const bundles = [];
|
|
92
|
+
for (const a of l.assets) {
|
|
93
|
+
record(a);
|
|
94
|
+
name(a.id);
|
|
95
|
+
name(a.bundle);
|
|
96
|
+
name(a.namespace);
|
|
97
|
+
str(a.path);
|
|
98
|
+
hash(a.sha256);
|
|
99
|
+
size(a.bytes, OTA_LIMITS.assetBytes);
|
|
100
|
+
totalBytes += a.bytes;
|
|
101
|
+
otaAssert(totalBytes <= OTA_LIMITS.releaseBytes, "OTA_LIMIT_EXCEEDED");
|
|
102
|
+
otaAssert(OTA_FORMATS.includes(a.format));
|
|
103
|
+
str(a.contentType);
|
|
104
|
+
otaAssert(a.contentType === (String(a.format).startsWith("JSON") || a.format === "IOS_XCSTRINGS" ? "application/json" : a.format === "IOS_STRINGS" ? "text/plain" : "application/xml"));
|
|
105
|
+
otaAssert(a.path === `assets/${a.id}/${a.sha256}`);
|
|
106
|
+
ids.push(a.id);
|
|
107
|
+
bundles.push(a.bundle);
|
|
108
|
+
}
|
|
109
|
+
unique(bundles);
|
|
110
|
+
otaAssert(x.requiredBundles.every((b) => bundles.includes(b)));
|
|
111
|
+
}
|
|
112
|
+
unique(locales);
|
|
113
|
+
unique(ids);
|
|
114
|
+
otaAssert(ids.length <= OTA_LIMITS.assets);
|
|
115
|
+
return x;
|
|
116
|
+
}
|
|
117
|
+
function validateCatalog(x, depth = 0) {
|
|
118
|
+
record(x);
|
|
119
|
+
otaAssert(depth < 40);
|
|
120
|
+
for (const [k, v] of Object.entries(x)) {
|
|
121
|
+
otaAssert(!unsafe.has(k), "OTA_UNSAFE_KEY");
|
|
122
|
+
if (typeof v !== "string") validateCatalog(v, depth + 1);
|
|
123
|
+
}
|
|
124
|
+
return x;
|
|
125
|
+
}
|
|
126
|
+
/** Reject encoded traversal before URL normalization. Credentials never leave the delivery scope. */
|
|
127
|
+
function deliveryUrl(base, path, prefix) {
|
|
128
|
+
otaAssert(!/%|\\|\?|#/.test(path) && !path.split("/").some((p) => p === "." || p === ".."), "OTA_UNSAFE_PATH");
|
|
129
|
+
const origin = new URL(base);
|
|
130
|
+
const url = new URL(path, origin);
|
|
131
|
+
otaAssert(url.origin === origin.origin && (url.protocol === "https:" || url.hostname === "localhost" || url.hostname === "127.0.0.1"), "OTA_UNSAFE_ORIGIN");
|
|
132
|
+
otaAssert(!url.username && !url.password && url.pathname.startsWith(prefix), "OTA_UNSAFE_PATH");
|
|
133
|
+
return url;
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/storage.ts
|
|
137
|
+
function createMemoryStorage() {
|
|
138
|
+
const states = /* @__PURE__ */ new Map();
|
|
139
|
+
const blobs = /* @__PURE__ */ new Map();
|
|
140
|
+
return {
|
|
141
|
+
async read(scope) {
|
|
142
|
+
return structuredClone(states.get(scope) ?? { revision: 0 });
|
|
143
|
+
},
|
|
144
|
+
async commit(scope, state, revision) {
|
|
145
|
+
if ((states.get(scope)?.revision ?? 0) !== revision) return false;
|
|
146
|
+
states.set(scope, structuredClone({
|
|
147
|
+
...state,
|
|
148
|
+
revision: revision + 1
|
|
149
|
+
}));
|
|
150
|
+
return true;
|
|
151
|
+
},
|
|
152
|
+
async readBlob(hash) {
|
|
153
|
+
return blobs.get(hash)?.slice();
|
|
154
|
+
},
|
|
155
|
+
async writeBlob(hash, bytes) {
|
|
156
|
+
blobs.set(hash, bytes.slice());
|
|
157
|
+
},
|
|
158
|
+
async clear(scope) {
|
|
159
|
+
const previous = states.get(scope);
|
|
160
|
+
states.set(scope, {
|
|
161
|
+
revision: (previous?.revision ?? 0) + 1,
|
|
162
|
+
cacheEpoch: (previous?.cacheEpoch ?? 0) + 1
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** IDB owns atomic metadata/pointer commits across tabs. Catalogs never use localStorage. */
|
|
168
|
+
function createIndexedDbStorage(databaseName = "syntholoc-ota-v1") {
|
|
169
|
+
let database;
|
|
170
|
+
function db() {
|
|
171
|
+
return database ??= new Promise((resolve, reject) => {
|
|
172
|
+
const request = indexedDB.open(databaseName, 1);
|
|
173
|
+
request.onupgradeneeded = () => {
|
|
174
|
+
request.result.createObjectStore("states");
|
|
175
|
+
request.result.createObjectStore("blobs");
|
|
176
|
+
};
|
|
177
|
+
request.onerror = () => {
|
|
178
|
+
database = void 0;
|
|
179
|
+
reject(request.error);
|
|
180
|
+
};
|
|
181
|
+
request.onsuccess = () => {
|
|
182
|
+
request.result.onversionchange = () => {
|
|
183
|
+
request.result.close();
|
|
184
|
+
database = void 0;
|
|
185
|
+
};
|
|
186
|
+
resolve(request.result);
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async function read(store, key) {
|
|
191
|
+
const d = await db();
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const transaction = d.transaction(store, "readonly");
|
|
194
|
+
const request = transaction.objectStore(store).get(key);
|
|
195
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
196
|
+
transaction.onerror = () => reject(transaction.error);
|
|
197
|
+
transaction.onabort = () => reject(transaction.error);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
async read(scope) {
|
|
202
|
+
return await read("states", scope) ?? { revision: 0 };
|
|
203
|
+
},
|
|
204
|
+
async commit(scope, state, revision) {
|
|
205
|
+
const d = await db();
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const tx = d.transaction("states", "readwrite");
|
|
208
|
+
const store = tx.objectStore("states");
|
|
209
|
+
const request = store.get(scope);
|
|
210
|
+
let matched = false;
|
|
211
|
+
request.onsuccess = () => {
|
|
212
|
+
if ((request.result?.revision ?? 0) === revision) {
|
|
213
|
+
store.put({
|
|
214
|
+
...state,
|
|
215
|
+
revision: revision + 1
|
|
216
|
+
}, scope);
|
|
217
|
+
matched = true;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
tx.oncomplete = () => resolve(matched);
|
|
221
|
+
tx.onerror = () => reject(tx.error);
|
|
222
|
+
tx.onabort = () => reject(tx.error);
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
async readBlob(hash) {
|
|
226
|
+
const d = await db();
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
const tx = d.transaction("blobs", "readwrite");
|
|
229
|
+
const store = tx.objectStore("blobs");
|
|
230
|
+
const request = store.get(hash);
|
|
231
|
+
let body;
|
|
232
|
+
request.onsuccess = () => {
|
|
233
|
+
const cached = request.result;
|
|
234
|
+
if (cached) {
|
|
235
|
+
body = cached instanceof Uint8Array ? cached : cached.bytes;
|
|
236
|
+
store.put({
|
|
237
|
+
bytes: body,
|
|
238
|
+
createdAt: Date.now()
|
|
239
|
+
}, hash);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
tx.oncomplete = () => resolve(body);
|
|
243
|
+
tx.onerror = () => reject(tx.error);
|
|
244
|
+
tx.onabort = () => reject(tx.error);
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
async writeBlob(hash, bytes) {
|
|
248
|
+
const d = await db();
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
const tx = d.transaction("blobs", "readwrite");
|
|
251
|
+
tx.objectStore("blobs").put({
|
|
252
|
+
bytes,
|
|
253
|
+
createdAt: Date.now()
|
|
254
|
+
}, hash);
|
|
255
|
+
tx.oncomplete = () => resolve();
|
|
256
|
+
tx.onerror = () => reject(tx.error);
|
|
257
|
+
tx.onabort = () => reject(tx.error);
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
async collect(scope, maxScopes = 8) {
|
|
261
|
+
const d = await db();
|
|
262
|
+
await new Promise((resolve, reject) => {
|
|
263
|
+
const tx = d.transaction(["states", "blobs"], "readwrite");
|
|
264
|
+
const states = tx.objectStore("states");
|
|
265
|
+
const blobs = tx.objectStore("blobs");
|
|
266
|
+
const request = states.openCursor();
|
|
267
|
+
const entries = [];
|
|
268
|
+
request.onsuccess = () => {
|
|
269
|
+
const cursor = request.result;
|
|
270
|
+
if (cursor) {
|
|
271
|
+
entries.push({
|
|
272
|
+
key: cursor.key,
|
|
273
|
+
state: cursor.value
|
|
274
|
+
});
|
|
275
|
+
cursor.continue();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
entries.sort((a, b) => (b.state.lastUsedAt ?? 0) - (a.state.lastUsedAt ?? 0));
|
|
279
|
+
const references = /* @__PURE__ */ new Set();
|
|
280
|
+
const cutoff = Date.now() - 864e5;
|
|
281
|
+
for (const [index, entry] of entries.entries()) {
|
|
282
|
+
if (entry.key !== scope && index >= maxScopes && (entry.state.lastUsedAt ?? 0) < cutoff) {
|
|
283
|
+
states.delete(entry.key);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
for (const set of [
|
|
287
|
+
entry.state.active,
|
|
288
|
+
entry.state.previous,
|
|
289
|
+
entry.state.candidate
|
|
290
|
+
]) for (const item of Array.isArray(set?.assets) ? set.assets : []) if (typeof item?.asset?.sha256 === "string") references.add(item.asset.sha256);
|
|
291
|
+
}
|
|
292
|
+
const scan = blobs.openCursor();
|
|
293
|
+
scan.onsuccess = () => {
|
|
294
|
+
const blob = scan.result;
|
|
295
|
+
if (!blob) return;
|
|
296
|
+
const value = blob.value;
|
|
297
|
+
if (!references.has(String(blob.key)) && (value.createdAt ?? 0) < cutoff) blob.delete();
|
|
298
|
+
blob.continue();
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
tx.oncomplete = () => resolve();
|
|
302
|
+
tx.onerror = () => reject(tx.error);
|
|
303
|
+
tx.onabort = () => reject(tx.error);
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
async clear(scope) {
|
|
307
|
+
const d = await db();
|
|
308
|
+
await new Promise((resolve, reject) => {
|
|
309
|
+
const tx = d.transaction("states", "readwrite");
|
|
310
|
+
const store = tx.objectStore("states");
|
|
311
|
+
const request = store.get(scope);
|
|
312
|
+
request.onsuccess = () => store.put({
|
|
313
|
+
revision: (request.result?.revision ?? 0) + 1,
|
|
314
|
+
cacheEpoch: (request.result?.cacheEpoch ?? 0) + 1
|
|
315
|
+
}, scope);
|
|
316
|
+
tx.oncomplete = () => resolve();
|
|
317
|
+
tx.onerror = () => reject(tx.error);
|
|
318
|
+
tx.onabort = () => reject(tx.error);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/index.ts
|
|
325
|
+
var OtaError = class extends Error {
|
|
326
|
+
status;
|
|
327
|
+
retryAfterMs;
|
|
328
|
+
constructor(message, status, retryAfterMs = 0) {
|
|
329
|
+
super(message);
|
|
330
|
+
this.status = status;
|
|
331
|
+
this.retryAfterMs = retryAfterMs;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const refreshes = /* @__PURE__ */ new WeakMap();
|
|
335
|
+
async function digest(bytes) {
|
|
336
|
+
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.slice().buffer))).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
337
|
+
}
|
|
338
|
+
function merge(a = {}, b = {}) {
|
|
339
|
+
const out = Object.create(null);
|
|
340
|
+
for (const [k, v] of Object.entries(a)) out[k] = typeof v === "string" ? v : merge(v);
|
|
341
|
+
for (const [k, v] of Object.entries(b)) out[k] = typeof v === "string" ? v : merge(typeof out[k] === "object" ? out[k] : {}, v);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
function notify(fn, arg) {
|
|
345
|
+
try {
|
|
346
|
+
fn?.(arg);
|
|
347
|
+
} catch {}
|
|
348
|
+
}
|
|
349
|
+
function createOtaClient(options) {
|
|
350
|
+
const o = {
|
|
351
|
+
...options,
|
|
352
|
+
bundles: [...options.bundles],
|
|
353
|
+
fallbackLocaleKeys: [...options.fallbackLocaleKeys ?? []]
|
|
354
|
+
};
|
|
355
|
+
otaAssert(OTA_SAFE_NAME.test(o.distributionId) && OTA_SAFE_NAME.test(o.channel) && !!o.catalogVersion && o.bundles.length > 0 && new Set(o.bundles).size === o.bundles.length, "OTA_INVALID_CONFIG");
|
|
356
|
+
const origin = new URL(o.baseUrl).origin;
|
|
357
|
+
const prefix = `/ota/v1/distributions/${o.distributionId}/channels/${o.channel}/`;
|
|
358
|
+
const channelUrl = deliveryUrl(origin, `${prefix}manifest.json`, prefix);
|
|
359
|
+
const scope = JSON.stringify([
|
|
360
|
+
origin,
|
|
361
|
+
o.distributionId,
|
|
362
|
+
o.channel,
|
|
363
|
+
o.catalogVersion,
|
|
364
|
+
o.localeKey,
|
|
365
|
+
o.fallbackLocaleKeys,
|
|
366
|
+
o.bundles
|
|
367
|
+
]);
|
|
368
|
+
const storage = o.storage ?? createMemoryStorage();
|
|
369
|
+
const pending = refreshes.get(storage) ?? /* @__PURE__ */ new Map();
|
|
370
|
+
refreshes.set(storage, pending);
|
|
371
|
+
const fetcher = o.fetch ?? globalThis.fetch;
|
|
372
|
+
const fallback = structuredClone(o.fallbackResources ?? {});
|
|
373
|
+
for (const namespaces of Object.values(fallback)) for (const catalog of Object.values(namespaces)) validateCatalog(catalog);
|
|
374
|
+
const localeKeys = [.../* @__PURE__ */ new Set([o.localeKey, ...o.fallbackLocaleKeys])];
|
|
375
|
+
let overlay = {};
|
|
376
|
+
let activeRelease;
|
|
377
|
+
let epoch = 0;
|
|
378
|
+
let nextCheckMonotonic;
|
|
379
|
+
let nextAttemptMonotonic;
|
|
380
|
+
const interval = Math.max(60, Math.min(86400, o.checkIntervalSeconds ?? 900)) * 1e3;
|
|
381
|
+
async function update(mutator) {
|
|
382
|
+
for (let i = 0; i < 10; i++) {
|
|
383
|
+
const s = await storage.read(scope);
|
|
384
|
+
const next = {
|
|
385
|
+
...mutator(s),
|
|
386
|
+
lastUsedAt: Date.now()
|
|
387
|
+
};
|
|
388
|
+
if (await storage.commit(scope, next, s.revision)) return {
|
|
389
|
+
...next,
|
|
390
|
+
revision: s.revision + 1
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
throw new OtaError("OTA_CACHE_CONFLICT");
|
|
394
|
+
}
|
|
395
|
+
async function parseSet(set) {
|
|
396
|
+
otaAssert(set && Array.isArray(set.assets) && set.assets.length <= OTA_LIMITS.assets && Number.isSafeInteger(set.generation), "OTA_CORRUPT_CACHE");
|
|
397
|
+
const resources = Object.create(null);
|
|
398
|
+
for (const { localeKey, asset } of set.assets) {
|
|
399
|
+
otaAssert(localeKeys.includes(localeKey) && o.bundles.includes(asset.bundle) && ["JSON_FLAT", "JSON_NESTED"].includes(asset.format), "OTA_UNSUPPORTED_FORMAT");
|
|
400
|
+
const bytes = await storage.readBlob(asset.sha256);
|
|
401
|
+
otaAssert(bytes && bytes.length === asset.bytes && await digest(bytes) === asset.sha256, "OTA_CORRUPT_CACHE");
|
|
402
|
+
const catalog = validateCatalog(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
403
|
+
if (asset.format === "JSON_FLAT") otaAssert(Object.values(catalog).every((v) => typeof v === "string"), "OTA_INVALID_CATALOG");
|
|
404
|
+
const namespaces = resources[localeKey] ??= Object.create(null);
|
|
405
|
+
otaAssert(!Object.hasOwn(namespaces, asset.namespace), "OTA_DUPLICATE_NAMESPACE");
|
|
406
|
+
namespaces[asset.namespace] = catalog;
|
|
407
|
+
}
|
|
408
|
+
otaAssert(localeKeys.every((l) => o.bundles.every((b) => set.assets.some((a) => a.localeKey === l && a.asset.bundle === b))), "OTA_MISSING_BUNDLE");
|
|
409
|
+
return resources;
|
|
410
|
+
}
|
|
411
|
+
async function request(url, limit, etag, signal) {
|
|
412
|
+
const headers = {};
|
|
413
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
414
|
+
if (o.credentialProvider) headers.Authorization = `Bearer ${await o.credentialProvider()}`;
|
|
415
|
+
const response = await fetcher(url, {
|
|
416
|
+
headers,
|
|
417
|
+
credentials: "omit",
|
|
418
|
+
redirect: "error",
|
|
419
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(o.timeoutMs ?? 15e3)]) : AbortSignal.timeout(o.timeoutMs ?? 15e3)
|
|
420
|
+
});
|
|
421
|
+
if (response.status === 304) return {
|
|
422
|
+
response,
|
|
423
|
+
bytes: void 0
|
|
424
|
+
};
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
let code = `OTA_HTTP_${response.status}`;
|
|
427
|
+
try {
|
|
428
|
+
const error = await bounded(response, 4096);
|
|
429
|
+
const body = JSON.parse(new TextDecoder().decode(error));
|
|
430
|
+
if (typeof body.code === "string" && /^OTA_[A-Z0-9_]{1,100}$/.test(body.code)) code = body.code;
|
|
431
|
+
} catch {}
|
|
432
|
+
const retry = response.headers.get("Retry-After");
|
|
433
|
+
const retryMs = retry ? /^\d+$/.test(retry) ? Number(retry) * 1e3 : Date.parse(retry) - Date.now() : 0;
|
|
434
|
+
throw new OtaError(code, response.status, Math.max(0, Math.min(864e5, retryMs || 0)));
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
response,
|
|
438
|
+
bytes: await bounded(response, limit)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function bounded(response, limit) {
|
|
442
|
+
otaAssert(Number(response.headers.get("Content-Length") ?? 0) <= limit, "OTA_LIMIT_EXCEEDED");
|
|
443
|
+
if (!response.body) return /* @__PURE__ */ new Uint8Array();
|
|
444
|
+
const reader = response.body.getReader();
|
|
445
|
+
const chunks = [];
|
|
446
|
+
let length = 0;
|
|
447
|
+
try {
|
|
448
|
+
for (;;) {
|
|
449
|
+
const r = await reader.read();
|
|
450
|
+
if (r.done) break;
|
|
451
|
+
length += r.value.length;
|
|
452
|
+
otaAssert(length <= limit, "OTA_LIMIT_EXCEEDED");
|
|
453
|
+
chunks.push(r.value);
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
await reader.cancel();
|
|
457
|
+
reader.releaseLock();
|
|
458
|
+
}
|
|
459
|
+
const bytes = new Uint8Array(length);
|
|
460
|
+
let offset = 0;
|
|
461
|
+
for (const c of chunks) {
|
|
462
|
+
bytes.set(c, offset);
|
|
463
|
+
offset += c.length;
|
|
464
|
+
}
|
|
465
|
+
return bytes;
|
|
466
|
+
}
|
|
467
|
+
async function loadCached() {
|
|
468
|
+
let s;
|
|
469
|
+
try {
|
|
470
|
+
s = await storage.read(scope);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
overlay = {};
|
|
473
|
+
activeRelease = void 0;
|
|
474
|
+
notify(o.onError, new OtaError("OTA_CORRUPT_CACHE"));
|
|
475
|
+
return {};
|
|
476
|
+
}
|
|
477
|
+
for (const set of [s.active, s.previous]) {
|
|
478
|
+
if (!set) continue;
|
|
479
|
+
try {
|
|
480
|
+
overlay = await parseSet(set);
|
|
481
|
+
activeRelease = set.releaseId;
|
|
482
|
+
await update((current) => current).catch(() => void 0);
|
|
483
|
+
return { releaseId: set.releaseId };
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
486
|
+
overlay = {};
|
|
487
|
+
activeRelease = void 0;
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
async function check(force, signal) {
|
|
491
|
+
const requestEpoch = epoch;
|
|
492
|
+
let cacheEpoch;
|
|
493
|
+
try {
|
|
494
|
+
let s = await storage.read(scope);
|
|
495
|
+
cacheEpoch = s.cacheEpoch ?? 0;
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
if (s.stopped) return { status: s.stopped === "retired" ? "retired" : "skipped" };
|
|
498
|
+
const remainingRetry = (s.nextAttempt ?? 0) - now;
|
|
499
|
+
if (nextAttemptMonotonic !== void 0 ? performance.now() < nextAttemptMonotonic : remainingRetry > 0 && remainingRetry <= Math.min(864e5, s.retryDelayMs ?? 36e5)) return { status: "skipped" };
|
|
500
|
+
const maxFreshDelay = Math.max(interval, Math.min(86400, s.channel?.recommendedCheckIntervalSeconds ?? 0) * 1e3) * 1.1;
|
|
501
|
+
const remainingFresh = (s.nextCheck ?? 0) - now;
|
|
502
|
+
const fresh = nextCheckMonotonic !== void 0 ? performance.now() < nextCheckMonotonic : remainingFresh > 0 && remainingFresh <= maxFreshDelay || s.lastChecked !== void 0 && now >= s.lastChecked && now - s.lastChecked < interval;
|
|
503
|
+
if (!force && fresh) return { status: "skipped" };
|
|
504
|
+
await storage.collect?.(scope).catch(() => void 0);
|
|
505
|
+
let reply = await request(channelUrl, OTA_LIMITS.manifestBytes, s.etag, signal);
|
|
506
|
+
if (!reply.bytes && !s.channel) reply = await request(channelUrl, OTA_LIMITS.manifestBytes, void 0, signal);
|
|
507
|
+
const channel = validateChannelManifest(reply.bytes ? JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(reply.bytes)) : s.channel);
|
|
508
|
+
otaAssert(channel.distributionId === o.distributionId && channel.channel === o.channel && channel.catalogVersion === o.catalogVersion, "OTA_CATALOG_INCOMPATIBLE");
|
|
509
|
+
otaAssert(channel.generation >= (s.highestGeneration ?? 0), "OTA_STALE_GENERATION");
|
|
510
|
+
otaAssert(channel.release.manifestPath === `${prefix}releases/${channel.release.id}/manifest.json`, "OTA_UNSAFE_PATH");
|
|
511
|
+
otaAssert(epoch === requestEpoch, "OTA_SCOPE_CHANGED");
|
|
512
|
+
s = await update((current) => {
|
|
513
|
+
otaAssert((current.cacheEpoch ?? 0) === cacheEpoch, "OTA_SCOPE_CHANGED");
|
|
514
|
+
otaAssert(channel.generation >= (current.highestGeneration ?? 0), "OTA_STALE_GENERATION");
|
|
515
|
+
return {
|
|
516
|
+
...current,
|
|
517
|
+
channel,
|
|
518
|
+
etag: reply.response.headers.get("ETag") ?? current.etag,
|
|
519
|
+
highestGeneration: channel.generation
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
if (s.active?.releaseId === channel.release.id) try {
|
|
523
|
+
const data = await parseSet(s.active);
|
|
524
|
+
await success(channel, cacheEpoch);
|
|
525
|
+
overlay = data;
|
|
526
|
+
activeRelease = s.active.releaseId;
|
|
527
|
+
return { status: "unchanged" };
|
|
528
|
+
} catch {}
|
|
529
|
+
const manifestUrl = deliveryUrl(origin, channel.release.manifestPath, prefix);
|
|
530
|
+
const fetched = await request(manifestUrl, OTA_LIMITS.manifestBytes, void 0, signal);
|
|
531
|
+
otaAssert(fetched.bytes && fetched.bytes.length === channel.release.bytes && await digest(fetched.bytes) === channel.release.sha256, "OTA_MANIFEST_INTEGRITY");
|
|
532
|
+
const manifest = validateReleaseManifest(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fetched.bytes)));
|
|
533
|
+
otaAssert(manifest.distributionId === o.distributionId && manifest.releaseId === channel.release.id && manifest.catalogVersion === o.catalogVersion, "OTA_CATALOG_INCOMPATIBLE");
|
|
534
|
+
otaAssert(manifest.requiredBundles.every((b) => o.bundles.includes(b)), "OTA_MISSING_BUNDLE");
|
|
535
|
+
const assets = [];
|
|
536
|
+
for (const localeKey of localeKeys) {
|
|
537
|
+
const locale = manifest.locales.find((l) => l.localeKey === localeKey);
|
|
538
|
+
otaAssert(locale, "OTA_LOCALE_UNKNOWN");
|
|
539
|
+
for (const bundle of o.bundles) {
|
|
540
|
+
const asset = locale.assets.find((a) => a.bundle === bundle);
|
|
541
|
+
otaAssert(asset, "OTA_MISSING_BUNDLE");
|
|
542
|
+
otaAssert(["JSON_FLAT", "JSON_NESTED"].includes(asset.format), "OTA_UNSUPPORTED_FORMAT");
|
|
543
|
+
let bytes = await storage.readBlob(asset.sha256);
|
|
544
|
+
if (!bytes || bytes.length !== asset.bytes || await digest(bytes) !== asset.sha256) {
|
|
545
|
+
const url = deliveryUrl(manifestUrl.href, asset.path, `${prefix}releases/${manifest.releaseId}/assets/`);
|
|
546
|
+
let response = await request(url, OTA_LIMITS.assetBytes, void 0, signal);
|
|
547
|
+
if (!response.bytes) response = await request(url, OTA_LIMITS.assetBytes, void 0, signal);
|
|
548
|
+
otaAssert(response.bytes && response.bytes.length === asset.bytes && await digest(response.bytes) === asset.sha256, "OTA_ASSET_INTEGRITY");
|
|
549
|
+
bytes = response.bytes;
|
|
550
|
+
validateCatalog(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
|
|
551
|
+
await storage.writeBlob(asset.sha256, bytes);
|
|
552
|
+
}
|
|
553
|
+
assets.push({
|
|
554
|
+
localeKey,
|
|
555
|
+
asset
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const candidate = {
|
|
560
|
+
releaseId: manifest.releaseId,
|
|
561
|
+
generation: channel.generation,
|
|
562
|
+
assets
|
|
563
|
+
};
|
|
564
|
+
await parseSet(candidate);
|
|
565
|
+
otaAssert(epoch === requestEpoch, "OTA_SCOPE_CHANGED");
|
|
566
|
+
await update((current) => {
|
|
567
|
+
otaAssert((current.cacheEpoch ?? 0) === cacheEpoch, "OTA_SCOPE_CHANGED");
|
|
568
|
+
otaAssert(candidate.generation >= (current.highestGeneration ?? 0), "OTA_STALE_GENERATION");
|
|
569
|
+
return {
|
|
570
|
+
...current,
|
|
571
|
+
candidate
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
await success(channel, cacheEpoch);
|
|
575
|
+
notify(o.onUpdateAvailable, candidate.releaseId);
|
|
576
|
+
return {
|
|
577
|
+
status: "available",
|
|
578
|
+
releaseId: candidate.releaseId
|
|
579
|
+
};
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const error = err instanceof OtaError ? err : err instanceof Error && /^OTA_[A-Z0-9_]{1,100}$/.test(err.message) ? err : new OtaError(err instanceof Error && err.name === "QuotaExceededError" ? "OTA_CACHE_QUOTA" : err instanceof Error && err.name === "TimeoutError" ? "OTA_TIMEOUT" : err instanceof SyntaxError ? "OTA_INVALID_DATA" : "OTA_UPDATE_FAILED");
|
|
582
|
+
const wire = error instanceof OtaError ? error : void 0;
|
|
583
|
+
await update((s) => {
|
|
584
|
+
if (cacheEpoch !== void 0 && (s.cacheEpoch ?? 0) !== cacheEpoch) return s;
|
|
585
|
+
const failures = (s.failures ?? 0) + 1;
|
|
586
|
+
const stopped = wire?.status === 410 && error.message === "OTA_DISTRIBUTION_RETIRED" ? "retired" : wire?.status === 401 || wire?.status === 403 ? "auth" : s.stopped;
|
|
587
|
+
const backoff = wire?.status === 404 ? 36e5 : Math.min(36e5, 15e3 * 2 ** Math.min(failures, 8));
|
|
588
|
+
const delay = Math.max(wire?.retryAfterMs ?? 0, backoff * (.8 + Math.random() * .4));
|
|
589
|
+
nextAttemptMonotonic = performance.now() + delay;
|
|
590
|
+
return {
|
|
591
|
+
...s,
|
|
592
|
+
failures,
|
|
593
|
+
stopped,
|
|
594
|
+
retryDelayMs: delay,
|
|
595
|
+
nextAttempt: Date.now() + delay
|
|
596
|
+
};
|
|
597
|
+
}).catch(() => void 0);
|
|
598
|
+
notify(o.onError, error);
|
|
599
|
+
return {
|
|
600
|
+
status: wire?.status === 410 && error.message === "OTA_DISTRIBUTION_RETIRED" ? "retired" : "error",
|
|
601
|
+
error: error.message
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async function success(channel, cacheEpoch) {
|
|
606
|
+
const delay = Math.max(interval, Math.min(86400, channel.recommendedCheckIntervalSeconds) * 1e3) * (1 + Math.random() * .1);
|
|
607
|
+
await update((s) => {
|
|
608
|
+
otaAssert((s.cacheEpoch ?? 0) === cacheEpoch, "OTA_SCOPE_CHANGED");
|
|
609
|
+
return {
|
|
610
|
+
...s,
|
|
611
|
+
lastChecked: Date.now(),
|
|
612
|
+
failures: 0,
|
|
613
|
+
nextAttempt: 0,
|
|
614
|
+
retryDelayMs: 0,
|
|
615
|
+
nextCheck: Date.now() + delay
|
|
616
|
+
};
|
|
617
|
+
});
|
|
618
|
+
nextCheckMonotonic = performance.now() + delay;
|
|
619
|
+
nextAttemptMonotonic = void 0;
|
|
620
|
+
}
|
|
621
|
+
function checkForUpdates(input = {}) {
|
|
622
|
+
const key = scope;
|
|
623
|
+
const existing = pending.get(key);
|
|
624
|
+
if (existing) return existing;
|
|
625
|
+
const promise = check(input.forceRefresh ?? false, input.signal).finally(() => pending.delete(key));
|
|
626
|
+
pending.set(key, promise);
|
|
627
|
+
return promise;
|
|
628
|
+
}
|
|
629
|
+
async function activate(releaseId) {
|
|
630
|
+
const candidate = (await storage.read(scope)).candidate;
|
|
631
|
+
otaAssert(candidate?.releaseId === releaseId, "OTA_CANDIDATE_UNKNOWN");
|
|
632
|
+
const data = await parseSet(candidate);
|
|
633
|
+
const requestEpoch = epoch;
|
|
634
|
+
await update((current) => {
|
|
635
|
+
otaAssert(epoch === requestEpoch && current.candidate?.releaseId === releaseId && current.candidate.generation === candidate.generation && candidate.generation >= (current.highestGeneration ?? 0), "OTA_STALE_GENERATION");
|
|
636
|
+
return {
|
|
637
|
+
...current,
|
|
638
|
+
previous: current.active,
|
|
639
|
+
active: candidate,
|
|
640
|
+
candidate: void 0
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
overlay = data;
|
|
644
|
+
activeRelease = releaseId;
|
|
645
|
+
notify(o.onActivated, releaseId);
|
|
646
|
+
await storage.collect?.(scope).catch(() => void 0);
|
|
647
|
+
}
|
|
648
|
+
function getResources() {
|
|
649
|
+
const result = Object.create(null);
|
|
650
|
+
for (const locale of [...localeKeys].reverse()) {
|
|
651
|
+
for (const [namespace, catalog] of Object.entries(fallback[locale] ?? {})) result[namespace] = merge(result[namespace], catalog);
|
|
652
|
+
for (const [namespace, catalog] of Object.entries(overlay[locale] ?? {})) result[namespace] = merge(result[namespace], catalog);
|
|
653
|
+
}
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
loadCached,
|
|
658
|
+
checkForUpdates,
|
|
659
|
+
activate,
|
|
660
|
+
getResources,
|
|
661
|
+
get activeReleaseId() {
|
|
662
|
+
return activeRelease;
|
|
663
|
+
},
|
|
664
|
+
async resetFailureState() {
|
|
665
|
+
await update((s) => ({
|
|
666
|
+
...s,
|
|
667
|
+
stopped: void 0,
|
|
668
|
+
nextAttempt: 0,
|
|
669
|
+
failures: 0,
|
|
670
|
+
lastChecked: void 0,
|
|
671
|
+
nextCheck: void 0,
|
|
672
|
+
etag: void 0
|
|
673
|
+
}));
|
|
674
|
+
nextCheckMonotonic = void 0;
|
|
675
|
+
nextAttemptMonotonic = void 0;
|
|
676
|
+
},
|
|
677
|
+
async clearCache() {
|
|
678
|
+
epoch++;
|
|
679
|
+
await storage.clear(scope);
|
|
680
|
+
overlay = {};
|
|
681
|
+
activeRelease = void 0;
|
|
682
|
+
nextCheckMonotonic = void 0;
|
|
683
|
+
nextAttemptMonotonic = void 0;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
//#endregion
|
|
688
|
+
export { createMemoryStorage as a, createIndexedDbStorage as i, createOtaClient as n, digest as r, OtaError as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntholoc/ota",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Verified offline-first Syntholoc translation delivery client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/ota/src/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"types": "./dist/ota/src/server.d.ts",
|
|
15
|
+
"default": "./dist/server.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/ota/src/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./server": {
|
|
31
|
+
"types": "./dist/ota/src/server.d.ts",
|
|
32
|
+
"default": "./dist/server.js"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@syntholoc/shared": "0.1.0",
|
|
38
|
+
"@types/node": "22.19.21",
|
|
39
|
+
"@types/proper-lockfile": "4.1.4",
|
|
40
|
+
"tsdown": "0.23.0",
|
|
41
|
+
"typescript": "7.0.2",
|
|
42
|
+
"vitest": "4.1.11"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://syntholoc.com",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/SergiyIva/syntholoc.git",
|
|
48
|
+
"directory": "packages/ota"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"proper-lockfile": "4.1.2"
|
|
52
|
+
}
|
|
53
|
+
}
|