bleam 0.0.8 → 0.0.10

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.
Files changed (96) hide show
  1. package/dist/cli.cjs +269 -22
  2. package/dist/cli.d.cts +32 -2
  3. package/dist/cli.d.ts +32 -2
  4. package/dist/cli.js +270 -23
  5. package/dist/platform.d.cts +2 -0
  6. package/dist/platform.d.ts +2 -0
  7. package/dist/{ui-1WepaMS4.d.cts → ui-Bg11tvlc.d.ts} +1 -1
  8. package/dist/{ui-Ce-pnUUA.d.ts → ui-Dd7SXdbg.d.cts} +7 -7
  9. package/dist/ui.d.cts +1 -1
  10. package/dist/ui.d.ts +1 -1
  11. package/dist/window.d.cts +1 -1
  12. package/dist/window.d.ts +1 -1
  13. package/package.json +5 -1
  14. package/templates/image-generation/app/index.tsx +1 -1
  15. package/templates/native/.gitattributes +1 -0
  16. package/templates/native/App.tsx +26 -0
  17. package/templates/native/app.json +12 -0
  18. package/templates/native/index.ts +8 -0
  19. package/templates/native/ios/.xcode.env +11 -0
  20. package/templates/native/ios/Bleam/AI/Flux2/Configuration/Flux2Config.swift +288 -0
  21. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryConfig.swift +233 -0
  22. package/templates/native/ios/Bleam/AI/Flux2/Configuration/MemoryOptimizationConfig.swift +157 -0
  23. package/templates/native/ios/Bleam/AI/Flux2/Configuration/ModelRegistry.swift +425 -0
  24. package/templates/native/ios/Bleam/AI/Flux2/Configuration/QuantizationConfig.swift +138 -0
  25. package/templates/native/ios/Bleam/AI/Flux2/Configuration/TransformerRepoProfile.swift +59 -0
  26. package/templates/native/ios/Bleam/AI/Flux2/Configuration/VAEConfig.swift +134 -0
  27. package/templates/native/ios/Bleam/AI/Flux2/Flux2Core.swift +55 -0
  28. package/templates/native/ios/Bleam/AI/Flux2/Loading/WeightLoader.swift +1192 -0
  29. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/FluxImageRunner.swift +1282 -0
  30. package/templates/native/ios/Bleam/AI/Flux2/Pipeline/LatentUtils.swift +191 -0
  31. package/templates/native/ios/Bleam/AI/Flux2/Scheduler/FlowMatchEulerScheduler.swift +279 -0
  32. package/templates/native/ios/Bleam/AI/Flux2/Text/KleinTextEncoder.swift +380 -0
  33. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Attention.swift +379 -0
  34. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Embeddings.swift +159 -0
  35. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FeedForward.swift +154 -0
  36. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2FusedKernels.swift +198 -0
  37. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2KVCache.swift +36 -0
  38. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Modulation.swift +193 -0
  39. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2ParallelAttention.swift +389 -0
  40. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2RoPE.swift +304 -0
  41. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2SingleBlock.swift +290 -0
  42. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2Transformer.swift +530 -0
  43. package/templates/native/ios/Bleam/AI/Flux2/Transformer/Flux2TransformerBlock.swift +362 -0
  44. package/templates/native/ios/Bleam/AI/Flux2/Utils/Flux2Debug.swift +101 -0
  45. package/templates/native/ios/Bleam/AI/Flux2/Utils/MLXCheckpoint.swift +118 -0
  46. package/templates/native/ios/Bleam/AI/Flux2/Utils/MemoryManager.swift +201 -0
  47. package/templates/native/ios/Bleam/AI/Flux2/VAE/AutoencoderKL.swift +476 -0
  48. package/templates/native/ios/Bleam/AI/Flux2/VAE/ResnetBlock.swift +316 -0
  49. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEDecoder.swift +120 -0
  50. package/templates/native/ios/Bleam/AI/Flux2/VAE/VAEEncoder.swift +136 -0
  51. package/templates/native/ios/Bleam/AppDelegate.swift +482 -0
  52. package/templates/native/ios/Bleam/Appearance.swift +62 -0
  53. package/templates/native/ios/Bleam/Bridging-Header.h +3 -0
  54. package/templates/native/ios/Bleam/Images.xcassets/AppIcon.appiconset/Contents.json +13 -0
  55. package/templates/native/ios/Bleam/Images.xcassets/Contents.json +6 -0
  56. package/templates/native/ios/Bleam/Info.plist +76 -0
  57. package/templates/native/ios/Bleam/PrivacyInfo.xcprivacy +48 -0
  58. package/templates/native/ios/Bleam/SceneDelegate.swift +58 -0
  59. package/templates/native/ios/Bleam/SplashScreen.storyboard +47 -0
  60. package/templates/native/ios/Bleam/Supporting/Expo.plist +6 -0
  61. package/templates/native/ios/Bleam/bleam.entitlements +10 -0
  62. package/templates/native/ios/Bleam.xcodeproj/project.pbxproj +1147 -0
  63. package/templates/native/ios/Bleam.xcodeproj/xcshareddata/xcschemes/Bleam.xcscheme +88 -0
  64. package/templates/native/ios/GenerationService/GenerationService.entitlements +10 -0
  65. package/templates/native/ios/GenerationService/ImageGenerationRunner.swift +591 -0
  66. package/templates/native/ios/GenerationService/Info.plist +31 -0
  67. package/templates/native/ios/GenerationService/main.swift +165 -0
  68. package/templates/native/ios/PlatformHelper/main.swift +726 -0
  69. package/templates/native/ios/Podfile +198 -0
  70. package/templates/native/ios/Podfile.lock +2284 -0
  71. package/templates/native/ios/Podfile.properties.json +5 -0
  72. package/templates/native/ios/Shared/Generation/GenerationServiceProtocol.swift +12 -0
  73. package/templates/native/ios/Shared/Generation/GenerationWorkerProtocol.swift +124 -0
  74. package/templates/native/metro.config.js +6 -0
  75. package/templates/native/modules/bleam-runtime/BleamRuntime.podspec +14 -0
  76. package/templates/native/modules/bleam-runtime/expo-module.config.json +8 -0
  77. package/templates/native/modules/bleam-runtime/ios/AIModule.swift +1258 -0
  78. package/templates/native/modules/bleam-runtime/ios/GenerationContracts.swift +135 -0
  79. package/templates/native/modules/bleam-runtime/ios/PlatformModule.swift +329 -0
  80. package/templates/native/modules/bleam-runtime/package.json +5 -0
  81. package/templates/native/package.json +39 -0
  82. package/templates/native/scripts/build/mlx-frameworks.ts +421 -0
  83. package/templates/native/scripts/start/index.ts +474 -0
  84. package/templates/native/scripts/start/known-failures.ts +20 -0
  85. package/templates/native/scripts/start/loader.ts +76 -0
  86. package/templates/native/scripts/start/native-state.ts +242 -0
  87. package/templates/native/scripts/start/package.json +4 -0
  88. package/templates/native/scripts/start/swift-packages.ts +414 -0
  89. package/templates/native/scripts/start/xcode-formatter.ts +106 -0
  90. package/templates/native/tsconfig.json +14 -0
  91. package/templates/native/yarn.lock +3749 -0
  92. package/templates/updates/README.md +272 -0
  93. package/templates/updates/src/index.ts +385 -0
  94. package/templates/updates/src/schema.ts +385 -0
  95. package/templates/updates/tsconfig.json +12 -0
  96. package/templates/updates/wrangler.jsonc +19 -0
@@ -0,0 +1,272 @@
1
+ # Bleam Update Service
2
+
3
+ This Worker is the local proof for issues #14 and #15. It resolves public OTA
4
+ and platform manifests from a local R2 simulation and serves release artifacts
5
+ through opaque Bleam download URLs.
6
+
7
+ It has no deployment command, no Cloudflare account ID, no remote binding, no
8
+ publisher endpoint, and no authentication. Running it cannot create or mutate
9
+ remote Cloudflare resources.
10
+
11
+ ## Local Development
12
+
13
+ The normal app workflow does not require a separate update-service command.
14
+ `bleam dev` starts the packaged Worker automatically at
15
+ `http://127.0.0.1:8787`, waits for `/v1/health`, and stops the Worker when the
16
+ owning dev session exits. If a healthy Worker is already running, it is reused
17
+ and left running.
18
+
19
+ Local R2 state and Worker logs persist globally at:
20
+
21
+ ```txt
22
+ ~/Library/Caches/bleam/updates/state
23
+ ~/Library/Caches/bleam/updates/worker.log
24
+ ```
25
+
26
+ Use Wrangler directly only while developing the update service itself.
27
+ Wrangler 4.110.0 or newer is required:
28
+
29
+ ```sh
30
+ wrangler dev --config services/updates/wrangler.jsonc --local \
31
+ --persist-to services/updates/.wrangler/state
32
+ ```
33
+
34
+ This repository-local R2 data lives under `services/updates/.wrangler/state`
35
+ and is ignored by Git. Seed repository-local artifacts with Wrangler, always
36
+ passing both `--local` and the same `--persist-to` directory:
37
+
38
+ ```sh
39
+ wrangler r2 object put bleam-updates-local/releases/com.example.notes/ota/0.0.10.json \
40
+ --file release.json \
41
+ --local \
42
+ --persist-to services/updates/.wrangler/state \
43
+ --config services/updates/wrangler.jsonc
44
+ ```
45
+
46
+ Run the end-to-end local proof with:
47
+
48
+ ```sh
49
+ sh services/updates/scripts/smoke.sh
50
+ ```
51
+
52
+ The smoke script creates temporary OTA and platform ZIP artifacts, seeds a
53
+ temporary local R2 state directory, starts Wrangler on `127.0.0.1:8791`, and
54
+ verifies both resolver routes, opaque artifact downloads, response hashes and
55
+ sizes, and a missing-release `404`.
56
+
57
+ Run the real CLI publication proof with:
58
+
59
+ ```sh
60
+ sh services/updates/scripts/ota-e2e.sh
61
+ ```
62
+
63
+ This starts an isolated local Worker/R2 instance, creates a temporary Bleam
64
+ project, runs `bleam publish ota`, resolves and downloads the published release,
65
+ verifies its SHA-256, and extracts its `bundle/bundle.js` payload.
66
+
67
+ Publish a signed full-app platform archive from a configured project with:
68
+
69
+ ```sh
70
+ bleam publish platform --service-url http://127.0.0.1:8787
71
+ ```
72
+
73
+ This runs the standalone Release build, packages the signed app as
74
+ `bleam-macos-app-zip-v1`, validates the declared bytes and SHA-256 through the
75
+ local service, and activates the stable `macos-arm64` resolver record.
76
+
77
+ With a configured Apple development team, run the full standalone lifecycle:
78
+
79
+ ```sh
80
+ sh services/updates/scripts/ota-installed-e2e.sh
81
+ ```
82
+
83
+ This builds and launches an isolated signed app, confirms an OTA download and
84
+ next-launch activation, publishes a hash-valid broken bundle, and confirms the
85
+ following launch rejects it and returns to the embedded bundle. It also verifies
86
+ the app's per-user helper remains registered with launchd after the app quits.
87
+ Finally, it publishes the signed app as a platform release, verifies and caches
88
+ it through the dedicated helper, applies Update and Relaunch, checks the rollback
89
+ backup and replacement receipt, validates the installed app and helper
90
+ signatures, and confirms a moved copy cannot self-update.
91
+
92
+ Before a release, run the same lifecycle with clean-state preflight and cleanup
93
+ assertions:
94
+
95
+ ```sh
96
+ sh services/updates/scripts/platform-release-readiness.sh
97
+ ```
98
+
99
+ This refuses existing state for the isolated test identity and verifies the app,
100
+ preferences, caches, helper copy, LaunchAgent, launchd registration, and update
101
+ receipts are absent again after the lifecycle. A release still requires this
102
+ check on a separate clean Mac; the local harness cannot substitute for fresh
103
+ hardware and user-account validation.
104
+
105
+ ## Public API
106
+
107
+ ```text
108
+ GET /v1/apps/:bundleIdentifier/ota/:runtimeVersion
109
+ GET /v1/apps/:bundleIdentifier/platform/macos-arm64
110
+ GET /v1/downloads/:downloadId
111
+ POST /v1/local/releases/ota
112
+ POST /v1/local/releases/platform
113
+ ```
114
+
115
+ The POST routes are accepted only on `localhost` and exist solely for local
116
+ publishing development. They validate the release record, artifact byte size,
117
+ and SHA-256 before writing the immutable artifact and activating its matching
118
+ resolver record.
119
+
120
+ `bundleIdentifier` is the Bleam app identity. A release manifest always binds
121
+ it to the Apple `teamIdentifier`; the Worker validates both from the stored
122
+ record before it responds.
123
+
124
+ Resolver responses are `Cache-Control: no-store` because release activation
125
+ and future rollout decisions must remain server-controlled. Download IDs are
126
+ immutable, so artifact responses are cacheable and use one-year immutable
127
+ caching. The R2 object key is present only in the internal download record.
128
+
129
+ ## Manifest Contract
130
+
131
+ ### Terminology
132
+
133
+ - An **OTA update** replaces the app's JavaScript bundle and assets without
134
+ replacing the installed `.app`.
135
+ - A **platform update** replaces the complete signed `.app`, including the
136
+ Bleam runtime, helper, native APIs, and native dependencies.
137
+ - The **runtime version** identifies the native environment required by an OTA
138
+ release. Compatibility is an exact string match.
139
+ - A **release manifest** describes one immutable artifact. A **resolver record**
140
+ selects the currently active release for an app, channel, and target.
141
+ - A release is **staged** after its artifact has been downloaded, verified, and
142
+ extracted. It becomes active on the next cold launch.
143
+
144
+ Both release forms use `schemaVersion: 1`, `channel: "stable"`, a unique
145
+ `releaseId`, `bundleIdentifier`, `teamIdentifier`, `publishedAt`, and an
146
+ artifact containing its format, opaque `downloadId`, exact byte size, lowercase
147
+ SHA-256 digest, and the resolver-provided download URL.
148
+
149
+ OTA releases add `bundleVersion` and an exact `runtimeVersion`:
150
+
151
+ ```json
152
+ {
153
+ "schemaVersion": 1,
154
+ "kind": "ota",
155
+ "bundleIdentifier": "com.example.notes",
156
+ "teamIdentifier": "ABCDE12345",
157
+ "channel": "stable",
158
+ "releaseId": "ota_release_01",
159
+ "bundleVersion": "1.2.0+4",
160
+ "runtimeVersion": "0.0.10",
161
+ "artifact": {
162
+ "format": "bleam-ota-zip-v1",
163
+ "downloadId": "ota_release_01",
164
+ "url": "http://127.0.0.1:8787/v1/downloads/ota_release_01",
165
+ "sizeBytes": 1843921,
166
+ "sha256": "b6d25f9f844da5e5d810501774e5949760a5a45f32e4d8f449905c61d6a8d58d"
167
+ },
168
+ "publishedAt": "2026-07-12T16:00:00Z"
169
+ }
170
+ ```
171
+
172
+ Platform releases add signed-app identity and target metadata:
173
+
174
+ ```json
175
+ {
176
+ "schemaVersion": 1,
177
+ "kind": "platform",
178
+ "bundleIdentifier": "com.example.notes",
179
+ "teamIdentifier": "ABCDE12345",
180
+ "channel": "stable",
181
+ "releaseId": "platform_release_01",
182
+ "appVersion": "1.3.0",
183
+ "buildNumber": "5",
184
+ "bleamPlatformVersion": "0.0.10",
185
+ "platformFingerprint": "82a09a48ea84e66a65615e30f0f62b7c10f2d5137f4eafa67ca44d90bd9c8823",
186
+ "redeploy": false,
187
+ "target": {
188
+ "os": "macos",
189
+ "architecture": "arm64",
190
+ "installLocation": "user-applications",
191
+ "minimumMacOSVersion": "13.4"
192
+ },
193
+ "artifact": {
194
+ "format": "bleam-macos-app-zip-v1",
195
+ "downloadId": "platform_release_01",
196
+ "url": "http://127.0.0.1:8787/v1/downloads/platform_release_01",
197
+ "sizeBytes": 182743921,
198
+ "sha256": "ab851b776c2656b3725a31882ca8a9bf0e64ae508703a32cd4f8400eb1bdc6ec"
199
+ },
200
+ "publishedAt": "2026-07-12T16:00:00Z"
201
+ }
202
+ ```
203
+
204
+ ### Platform Feed And Archive
205
+
206
+ The initial platform feed is the stable, Apple-silicon resolver at
207
+ `GET /v1/apps/:bundleIdentifier/platform/macos-arm64`. It returns only releases
208
+ targeting `macos`, `arm64`, and the `user-applications` install location. The
209
+ helper must reject a release whose app version is not semantic, whose build
210
+ number is not decimal, or whose minimum macOS version exceeds the current OS.
211
+
212
+ `platformFingerprint` is a deterministic SHA-256 over Bleam runtime identity
213
+ and supported signed bundle inputs. It excludes developer TypeScript and
214
+ ordinary OTA assets. Local publication rejects an unchanged fingerprint unless
215
+ the manifest records an explicit `redeploy: true`; redeployment does not bypass
216
+ artifact or installed-app verification. Legacy manifests without a fingerprint
217
+ remain readable only so their next normal publication can establish one.
218
+
219
+ `bleam-macos-app-zip-v1` is a ZIP containing exactly one top-level `app/`
220
+ directory and exactly one complete `.app` beneath it:
221
+
222
+ ```text
223
+ app/<display name>.app/
224
+ ```
225
+
226
+ The archive must not contain another top-level entry, multiple app bundles,
227
+ absolute paths, or entries that escape `app/` through `..` or symlink traversal.
228
+ The helper extracts into the app-specific cache, never directly into
229
+ `~/Applications`, and treats the extracted bundle as untrusted until checkpoint
230
+ verification of SHA-256, bundle ID, team ID, app/build versions, architecture,
231
+ and code signature succeeds. The release ID and artifact download ID are
232
+ immutable; changing app bytes requires a new release and download ID.
233
+
234
+ The R2 layout is internal:
235
+
236
+ ```text
237
+ releases/<bundleIdentifier>/ota/<runtimeVersion>.json
238
+ releases/<bundleIdentifier>/platform/macos-arm64.json
239
+ downloads/<downloadId>.json
240
+ artifacts/<private release path>
241
+ ```
242
+
243
+ ## Verification And Recovery
244
+
245
+ Publication validates the manifest schema, declared artifact byte size, and
246
+ SHA-256 before activating a resolver record. The app repeats size and SHA-256
247
+ verification after download and rejects mismatched artifacts before extraction.
248
+ Both release types bind the artifact to a bundle identifier and Apple team
249
+ identifier. OTA releases additionally require the app's exact runtime version.
250
+
251
+ OTA bundles are cached under
252
+ `~/Library/Caches/Bleam/OTA/<bundleIdentifier>/<releaseId>/`. The active release
253
+ ID is stored separately from the bundle so an incomplete download cannot become
254
+ active. A staged release is selected only on a later cold launch and is marked
255
+ as attempted before React Native starts.
256
+
257
+ Startup is confirmed when React Native reports either JavaScript loading on the
258
+ bridge runtime or root content appearing on Fabric. If the app exits or the
259
+ bundle fails before confirmation, the next launch rejects that release, clears
260
+ its active and attempted state, and loads the embedded `main.jsbundle`. A
261
+ rejected release is not selected again while it remains the resolver's active
262
+ release.
263
+
264
+ Platform artifacts are downloaded and applied by the per-app helper. It verifies
265
+ the artifact hash, archive layout, bundle and team identity, app and build
266
+ versions, Bleam runtime and platform fingerprint, arm64 architecture, code
267
+ signature, and minimum macOS version before replacement. Hosted authentication,
268
+ ownership, entitlement, and deployment remain separate follow-up work.
269
+
270
+ Standalone builds configured with `BLEAM_UPDATE_SERVICE_URL` check the matching
271
+ OTA resolver after React starts. A verified release is cached for the next cold
272
+ launch and follows the activation and recovery behavior above.
@@ -0,0 +1,385 @@
1
+ import {
2
+ compareRuntimeVersions,
3
+ type DownloadRecord,
4
+ isBundleIdentifier,
5
+ isDownloadId,
6
+ isRuntimeVersion,
7
+ parseDownloadRecord,
8
+ parsePublishedRelease,
9
+ parseStoredRelease,
10
+ resolveRelease,
11
+ type StoredRelease,
12
+ } from './schema'
13
+
14
+ interface StoredObject {
15
+ body: ReadableStream<Uint8Array>
16
+ httpEtag: string
17
+ size: number
18
+ text(): Promise<string>
19
+ }
20
+
21
+ interface UpdatesBucket {
22
+ get(key: string): Promise<StoredObject | null>
23
+ put(
24
+ key: string,
25
+ value: string | ArrayBuffer,
26
+ options?: { httpMetadata?: { contentType: string } },
27
+ ): Promise<unknown>
28
+ }
29
+
30
+ interface Env {
31
+ UPDATES: UpdatesBucket
32
+ }
33
+
34
+ const jsonHeaders = {
35
+ 'content-type': 'application/json; charset=utf-8',
36
+ 'cache-control': 'no-store',
37
+ }
38
+
39
+ function json(value: unknown, status = 200, method = 'GET') {
40
+ return new Response(method === 'HEAD' ? null : JSON.stringify(value), {
41
+ status,
42
+ headers: jsonHeaders,
43
+ })
44
+ }
45
+
46
+ function error(code: string, status: number, method: string) {
47
+ return json({ error: { code } }, status, method)
48
+ }
49
+
50
+ function notFound(method: string) {
51
+ return error('not_found', 404, method)
52
+ }
53
+
54
+ function unavailable(method: string) {
55
+ return error('invalid_release', 500, method)
56
+ }
57
+
58
+ function methodNotAllowed() {
59
+ return new Response(null, {
60
+ status: 405,
61
+ headers: { allow: 'GET, HEAD' },
62
+ })
63
+ }
64
+
65
+ function isLocalRequest(request: Request) {
66
+ const hostname = new URL(request.url).hostname
67
+ return hostname === '127.0.0.1' || hostname === 'localhost'
68
+ }
69
+
70
+ function base64Bytes(value: string) {
71
+ const binary = atob(value)
72
+ const bytes = new Uint8Array(binary.length)
73
+ for (let index = 0; index < binary.length; index += 1) {
74
+ bytes[index] = binary.charCodeAt(index)
75
+ }
76
+ return bytes
77
+ }
78
+
79
+ async function sha256(bytes: Uint8Array) {
80
+ const digest = await crypto.subtle.digest(
81
+ 'SHA-256',
82
+ bytes.buffer.slice(
83
+ bytes.byteOffset,
84
+ bytes.byteOffset + bytes.byteLength,
85
+ ) as ArrayBuffer,
86
+ )
87
+ return Array.from(new Uint8Array(digest), (byte) =>
88
+ byte.toString(16).padStart(2, '0'),
89
+ ).join('')
90
+ }
91
+
92
+ async function publishLocalRelease(
93
+ request: Request,
94
+ bucket: UpdatesBucket,
95
+ expectedKind: StoredRelease['kind'],
96
+ ) {
97
+ if (!isLocalRequest(request)) return notFound(request.method)
98
+
99
+ let input: Record<string, unknown>
100
+ try {
101
+ input = (await request.json()) as Record<string, unknown>
102
+ } catch {
103
+ return error('invalid_request', 400, request.method)
104
+ }
105
+
106
+ if (typeof input.artifactBase64 !== 'string') {
107
+ return error('invalid_request', 400, request.method)
108
+ }
109
+
110
+ try {
111
+ const release = parsePublishedRelease(input.release)
112
+ if (release.kind !== expectedKind) {
113
+ return error('invalid_request', 400, request.method)
114
+ }
115
+ const artifact = base64Bytes(input.artifactBase64)
116
+ if (artifact.byteLength !== release.artifact.sizeBytes) {
117
+ return error('invalid_request', 400, request.method)
118
+ }
119
+ if ((await sha256(artifact)) !== release.artifact.sha256) {
120
+ return error('invalid_request', 400, request.method)
121
+ }
122
+
123
+ const releaseKey =
124
+ release.kind === 'ota'
125
+ ? `releases/${release.bundleIdentifier}/ota/${release.runtimeVersion}.json`
126
+ : `releases/${release.bundleIdentifier}/platform/macos-arm64.json`
127
+ if (release.kind === 'platform') {
128
+ const currentValue = await readStoredJson(bucket, releaseKey)
129
+ const current = currentValue
130
+ ? parseStoredRelease(currentValue)
131
+ : undefined
132
+ const unchanged =
133
+ current?.kind === 'platform' &&
134
+ current.platformFingerprint !== undefined &&
135
+ current.platformFingerprint === release.platformFingerprint
136
+ if (
137
+ current?.kind === 'platform' &&
138
+ compareRuntimeVersions(
139
+ release.bleamPlatformVersion,
140
+ current.bleamPlatformVersion,
141
+ ) < 0
142
+ ) {
143
+ return error('platform_downgrade', 409, request.method)
144
+ }
145
+ if (unchanged && !release.redeploy) {
146
+ return error('platform_unchanged', 409, request.method)
147
+ }
148
+ if (!unchanged && release.redeploy) {
149
+ return error('redeploy_not_required', 400, request.method)
150
+ }
151
+ }
152
+
153
+ const artifactKey = `artifacts/${release.kind}/${release.artifact.downloadId}.zip`
154
+ const download: DownloadRecord = {
155
+ schemaVersion: 1,
156
+ downloadId: release.artifact.downloadId,
157
+ artifactKey,
158
+ contentType: 'application/zip',
159
+ }
160
+ await bucket.put(artifactKey, artifact.buffer, {
161
+ httpMetadata: { contentType: 'application/zip' },
162
+ })
163
+ await bucket.put(
164
+ `downloads/${download.downloadId}.json`,
165
+ JSON.stringify(download),
166
+ { httpMetadata: { contentType: 'application/json' } },
167
+ )
168
+ await bucket.put(releaseKey, JSON.stringify(release), {
169
+ httpMetadata: { contentType: 'application/json' },
170
+ })
171
+ return json(
172
+ resolveRelease(
173
+ release,
174
+ new URL(`/v1/downloads/${download.downloadId}`, request.url).toString(),
175
+ ),
176
+ 201,
177
+ )
178
+ } catch {
179
+ return error('invalid_request', 400, request.method)
180
+ }
181
+ }
182
+
183
+ function decodedSegment(value: string): string | undefined {
184
+ try {
185
+ return decodeURIComponent(value)
186
+ } catch {
187
+ return undefined
188
+ }
189
+ }
190
+
191
+ async function readStoredJson(bucket: UpdatesBucket, key: string) {
192
+ const object = await bucket.get(key)
193
+ if (!object) return undefined
194
+
195
+ try {
196
+ return JSON.parse(await object.text()) as unknown
197
+ } catch {
198
+ return undefined
199
+ }
200
+ }
201
+
202
+ function releaseResponse(
203
+ release: StoredRelease,
204
+ request: Request,
205
+ method: string,
206
+ ) {
207
+ const artifactUrl = new URL(
208
+ `/v1/downloads/${release.artifact.downloadId}`,
209
+ request.url,
210
+ ).toString()
211
+ return json(resolveRelease(release, artifactUrl), 200, method)
212
+ }
213
+
214
+ async function resolveOta(
215
+ bucket: UpdatesBucket,
216
+ request: Request,
217
+ bundleIdentifier: string,
218
+ runtimeVersion: string,
219
+ method: string,
220
+ ) {
221
+ if (
222
+ !isBundleIdentifier(bundleIdentifier) ||
223
+ !isRuntimeVersion(runtimeVersion)
224
+ ) {
225
+ return notFound(method)
226
+ }
227
+
228
+ const stored = await readStoredJson(
229
+ bucket,
230
+ `releases/${bundleIdentifier}/ota/${runtimeVersion}.json`,
231
+ )
232
+ if (!stored) return notFound(method)
233
+
234
+ try {
235
+ const release = parseStoredRelease(stored)
236
+ if (
237
+ release.kind !== 'ota' ||
238
+ release.bundleIdentifier !== bundleIdentifier ||
239
+ release.runtimeVersion !== runtimeVersion
240
+ ) {
241
+ return unavailable(method)
242
+ }
243
+ return releaseResponse(release, request, method)
244
+ } catch {
245
+ return unavailable(method)
246
+ }
247
+ }
248
+
249
+ async function resolvePlatform(
250
+ bucket: UpdatesBucket,
251
+ request: Request,
252
+ bundleIdentifier: string,
253
+ target: string,
254
+ method: string,
255
+ ) {
256
+ if (!isBundleIdentifier(bundleIdentifier) || target !== 'macos-arm64') {
257
+ return notFound(method)
258
+ }
259
+
260
+ const stored = await readStoredJson(
261
+ bucket,
262
+ `releases/${bundleIdentifier}/platform/${target}.json`,
263
+ )
264
+ if (!stored) return notFound(method)
265
+
266
+ try {
267
+ const release = parseStoredRelease(stored)
268
+ if (
269
+ release.kind !== 'platform' ||
270
+ release.bundleIdentifier !== bundleIdentifier ||
271
+ `${release.target.os}-${release.target.architecture}` !== target
272
+ ) {
273
+ return unavailable(method)
274
+ }
275
+ return releaseResponse(release, request, method)
276
+ } catch {
277
+ return unavailable(method)
278
+ }
279
+ }
280
+
281
+ async function downloadArtifact(
282
+ bucket: UpdatesBucket,
283
+ downloadId: string,
284
+ method: string,
285
+ ) {
286
+ if (!isDownloadId(downloadId)) return notFound(method)
287
+
288
+ const stored = await readStoredJson(bucket, `downloads/${downloadId}.json`)
289
+ if (!stored) return notFound(method)
290
+
291
+ let download: DownloadRecord
292
+ try {
293
+ download = parseDownloadRecord(stored)
294
+ } catch {
295
+ return unavailable(method)
296
+ }
297
+
298
+ if (download.downloadId !== downloadId) return unavailable(method)
299
+
300
+ const artifact = await bucket.get(download.artifactKey)
301
+ if (!artifact) return notFound(method)
302
+
303
+ const headers = new Headers({
304
+ 'content-type': download.contentType,
305
+ 'cache-control': 'public, max-age=31536000, immutable',
306
+ etag: artifact.httpEtag,
307
+ 'content-length': String(artifact.size),
308
+ })
309
+ return new Response(method === 'HEAD' ? null : artifact.body, {
310
+ status: 200,
311
+ headers,
312
+ })
313
+ }
314
+
315
+ export default {
316
+ async fetch(request: Request, env: Env): Promise<Response> {
317
+ const url = new URL(request.url)
318
+ if (request.method === 'GET' && url.pathname === '/v1/health') {
319
+ return json({ healthy: true })
320
+ }
321
+ if (
322
+ request.method === 'POST' &&
323
+ url.pathname === '/v1/local/releases/ota'
324
+ ) {
325
+ return publishLocalRelease(request, env.UPDATES, 'ota')
326
+ }
327
+ if (
328
+ request.method === 'POST' &&
329
+ url.pathname === '/v1/local/releases/platform'
330
+ ) {
331
+ return publishLocalRelease(request, env.UPDATES, 'platform')
332
+ }
333
+
334
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
335
+ return methodNotAllowed()
336
+ }
337
+
338
+ const path = url.pathname.split('/').filter(Boolean)
339
+ const method = request.method
340
+
341
+ if (
342
+ path.length === 5 &&
343
+ path[0] === 'v1' &&
344
+ path[1] === 'apps' &&
345
+ path[3] === 'ota'
346
+ ) {
347
+ const bundleIdentifier = decodedSegment(path[2])
348
+ const runtimeVersion = decodedSegment(path[4])
349
+ if (!bundleIdentifier || !runtimeVersion) return notFound(method)
350
+ return resolveOta(
351
+ env.UPDATES,
352
+ request,
353
+ bundleIdentifier,
354
+ runtimeVersion,
355
+ method,
356
+ )
357
+ }
358
+
359
+ if (
360
+ path.length === 5 &&
361
+ path[0] === 'v1' &&
362
+ path[1] === 'apps' &&
363
+ path[3] === 'platform'
364
+ ) {
365
+ const bundleIdentifier = decodedSegment(path[2])
366
+ const target = decodedSegment(path[4])
367
+ if (!bundleIdentifier || !target) return notFound(method)
368
+ return resolvePlatform(
369
+ env.UPDATES,
370
+ request,
371
+ bundleIdentifier,
372
+ target,
373
+ method,
374
+ )
375
+ }
376
+
377
+ if (path.length === 3 && path[0] === 'v1' && path[1] === 'downloads') {
378
+ const downloadId = decodedSegment(path[2])
379
+ if (!downloadId) return notFound(method)
380
+ return downloadArtifact(env.UPDATES, downloadId, method)
381
+ }
382
+
383
+ return notFound(method)
384
+ },
385
+ }