@mentra/cloud-protocol 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @mentra/cloud-protocol
2
+
3
+ The MentraOS phone ↔ cloud wire protocol: message schemas, envelopes, and
4
+ error types shared by everything that speaks to the MentraOS cloud. Pure
5
+ TypeScript + [zod](https://github.com/colinhacks/zod) — no server or native
6
+ dependencies.
7
+
8
+ This is a **leaf package**: [`@mentra/cloud-client`](https://www.npmjs.com/package/@mentra/cloud-client)
9
+ and [`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine) depend on
10
+ it, and the MentraOS cloud runtime consumes the same schemas server-side, so
11
+ both ends of the wire validate against one definition.
12
+
13
+ ## Install
14
+
15
+ You normally get this package automatically as a dependency of
16
+ `@mentra/cloud-client` or a peer of `@mentra/engine`. For direct use:
17
+
18
+ ```sh
19
+ npm install @mentra/cloud-protocol@dev
20
+ ```
21
+
22
+ > Currently published on the `dev` dist-tag (prerelease channel).
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import {envelopeSchema, PROTOCOL_MAJOR, type Envelope} from "@mentra/cloud-protocol";
28
+
29
+ const message: Envelope = envelopeSchema.parse(JSON.parse(raw));
30
+
31
+ // or per-module subpaths:
32
+ import {normalizePhotoSizeTier} from "@mentra/cloud-protocol/camera";
33
+ ```
34
+
35
+ Modules: `envelope`, `messages`, `handshake`, `control`, `camera`, `audio`,
36
+ `maps`, `errors` — each importable as `@mentra/cloud-protocol/<module>`.
37
+
38
+ The package ships TypeScript source (`main: ./src/index.ts`) and targets
39
+ consumers that compile TS themselves (Metro / bundlers / tsc); it is not
40
+ precompiled for plain Node `require`.
41
+
42
+ ## Part of MentraOS
43
+
44
+ Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
45
+ under `cloud-v2/packages/protocol`. Issues and contributions welcome there.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@mentra/cloud-protocol",
3
+ "version": "0.1.0-beta.0",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./*": "./src/*.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "!src/**/*.test.ts"
13
+ ],
14
+ "scripts": {
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "zod": "^3.24.1"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/Mentra-Community/MentraOS.git",
26
+ "directory": "cloud-v2/packages/protocol"
27
+ },
28
+ "description": "MentraOS phone-cloud wire protocol — message schemas, envelopes and error types (zod)",
29
+ "keywords": [
30
+ "mentra",
31
+ "mentraos",
32
+ "protocol",
33
+ "schemas",
34
+ "zod"
35
+ ],
36
+ "homepage": "https://github.com/Mentra-Community/MentraOS/tree/dev/cloud-v2/packages/protocol#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/Mentra-Community/MentraOS/issues"
39
+ }
40
+ }
package/src/audio.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @fileoverview Canonical audio wire types for the Mentra Runtime protocol.
3
+ *
4
+ * zod schemas + inferred TS types for the audio service's subscription model
5
+ * and result types. These are the single source of truth; `src/audio.types.ts`
6
+ * re-exports the subscription types from here and adds `subscriptionKey()`.
7
+ *
8
+ * Mirrors docs/issues/002-cloud-runtime/audio/spec.md
9
+ * ("Subscription model" and "Result types"). Pure + isomorphic: no server
10
+ * imports, safe to bundle into the RN client.
11
+ */
12
+ import { z } from "zod";
13
+
14
+ // --- Subscription model -----------------------------------------------------
15
+
16
+ export const languageSourceSchema = z.discriminatedUnion("mode", [
17
+ z.object({ mode: z.literal("specific"), code: z.string() }),
18
+ z.object({ mode: z.literal("auto"), hints: z.array(z.string()).optional() }),
19
+ ]);
20
+ export type LanguageSource = z.infer<typeof languageSourceSchema>;
21
+
22
+ export const transcriptionSubscriptionSchema = z.object({
23
+ kind: z.literal("transcription"),
24
+ language: languageSourceSchema,
25
+ });
26
+ export type TranscriptionSubscription = z.infer<
27
+ typeof transcriptionSubscriptionSchema
28
+ >;
29
+
30
+ export const translationSubscriptionSchema = z.object({
31
+ kind: z.literal("translation"),
32
+ source: languageSourceSchema,
33
+ target: z.string(),
34
+ });
35
+ export type TranslationSubscription = z.infer<
36
+ typeof translationSubscriptionSchema
37
+ >;
38
+
39
+ export const audioSubscriptionSchema = z.discriminatedUnion("kind", [
40
+ transcriptionSubscriptionSchema,
41
+ translationSubscriptionSchema,
42
+ ]);
43
+ export type AudioSubscription = z.infer<typeof audioSubscriptionSchema>;
44
+
45
+ // --- UDP liveness -----------------------------------------------------------
46
+
47
+ export const UDP_LIVENESS_PROBE_PREFIX = "mentra:udp-probe:";
48
+
49
+ export const udpLivenessAckPayloadSchema = z.object({
50
+ sessionId: z.string(),
51
+ sessionTag: z.number().int(),
52
+ probeId: z.string(),
53
+ receivedAt: z.number().int(),
54
+ });
55
+ export type UdpLivenessAckPayload = z.infer<typeof udpLivenessAckPayloadSchema>;
56
+
57
+ // --- Result types -----------------------------------------------------------
58
+
59
+ export const transcriptionTokenSchema = z.object({
60
+ text: z.string(),
61
+ startMs: z.number(),
62
+ endMs: z.number(),
63
+ confidence: z.number(),
64
+ isFinal: z.boolean(),
65
+ speaker: z.string().optional(),
66
+ // per-token; mid-sentence language switches are possible
67
+ detectedLanguage: z.string().optional(),
68
+ });
69
+ export type TranscriptionToken = z.infer<typeof transcriptionTokenSchema>;
70
+
71
+ export const transcriptionDataSchema = z.object({
72
+ userId: z.string(),
73
+ subscription: transcriptionSubscriptionSchema,
74
+
75
+ // Aggregated for simple consumers
76
+ text: z.string(),
77
+ isFinal: z.boolean(),
78
+ utteranceId: z.string().optional(),
79
+ speakerId: z.string().optional(),
80
+ startMs: z.number(),
81
+ endMs: z.number(),
82
+ durationMs: z.number().optional(),
83
+ confidence: z.number().optional(),
84
+
85
+ // Language resolution
86
+ resolvedLanguage: z.string(),
87
+ languageDetected: z.boolean(),
88
+
89
+ // Per-token detail for consumers that need it
90
+ tokens: z.array(transcriptionTokenSchema),
91
+
92
+ provider: z.string(),
93
+ timestamp: z.number(),
94
+ });
95
+ export type TranscriptionData = z.infer<typeof transcriptionDataSchema>;
96
+
97
+ export const translationDataSchema = z.object({
98
+ userId: z.string(),
99
+ subscription: translationSubscriptionSchema,
100
+
101
+ text: z.string(), // translated
102
+ originalText: z.string().optional(), // source-language text
103
+ isFinal: z.boolean(),
104
+ utteranceId: z.string().optional(),
105
+ speakerId: z.string().optional(),
106
+ startMs: z.number(),
107
+ endMs: z.number(),
108
+ durationMs: z.number().optional(),
109
+ confidence: z.number().optional(),
110
+
111
+ source: z.object({
112
+ language: z.string(), // resolved (specified or detected)
113
+ detected: z.boolean(),
114
+ confidence: z.number().optional(),
115
+ }),
116
+ target: z.object({ language: z.string() }),
117
+
118
+ provider: z.string(),
119
+ timestamp: z.number(),
120
+ });
121
+ export type TranslationData = z.infer<typeof translationDataSchema>;
package/src/camera.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @fileoverview Canonical camera wire types: managed photo + managed stream.
3
+ *
4
+ * zod schemas + inferred TS types for the camera service. The request/result
5
+ * shapes ride REST; `photo.ready` / `photo.error` are WebSocket push events
6
+ * registered into the message union (messages.ts). Pure + isomorphic: no server
7
+ * imports, safe to bundle into the client.
8
+ *
9
+ * Mirrors docs/issues/002-cloud-runtime/camera/spec.md.
10
+ */
11
+ import { z } from "zod";
12
+
13
+ // --- Managed photo ----------------------------------------------------------
14
+
15
+ /** Device-canonical photo size tier (matches miniapp SDK and ASG). */
16
+ export const photoSizeCanonicalSchema = z.enum(["low", "medium", "high", "max"]);
17
+ export type PhotoSizeTier = z.infer<typeof photoSizeCanonicalSchema>;
18
+
19
+ /** Accepted on the wire: canonical names plus legacy cloud aliases. */
20
+ const photoSizeInputSchema = z.enum([
21
+ "low",
22
+ "medium",
23
+ "high",
24
+ "max",
25
+ "small",
26
+ "large",
27
+ "full",
28
+ ]);
29
+
30
+ /**
31
+ * Normalize a photo size string to the device-canonical tier.
32
+ * Legacy aliases: small→low, large→high, full→max.
33
+ */
34
+ export function normalizePhotoSizeTier(value: string): PhotoSizeTier {
35
+ switch (value) {
36
+ case "small":
37
+ return "low";
38
+ case "large":
39
+ return "high";
40
+ case "full":
41
+ return "max";
42
+ case "low":
43
+ case "medium":
44
+ case "high":
45
+ case "max":
46
+ return value;
47
+ default:
48
+ throw new Error(`invalid photo size: ${value}`);
49
+ }
50
+ }
51
+
52
+ const photoCompressInputSchema = z.enum(["none", "low", "medium", "high", "heavy"]);
53
+
54
+ /** Normalize compression aliases to the cloud wire enum. */
55
+ export function normalizePhotoCompress(
56
+ value: string,
57
+ ): "none" | "medium" | "heavy" {
58
+ switch (value) {
59
+ case "low":
60
+ case "medium":
61
+ return "medium";
62
+ case "high":
63
+ case "heavy":
64
+ return "heavy";
65
+ case "none":
66
+ return "none";
67
+ default:
68
+ throw new Error(`invalid photo compress: ${value}`);
69
+ }
70
+ }
71
+
72
+ export const photoOptionsSchema = z.object({
73
+ size: photoSizeInputSchema
74
+ .optional()
75
+ .transform((value) => (value === undefined ? undefined : normalizePhotoSizeTier(value))),
76
+ compress: photoCompressInputSchema
77
+ .optional()
78
+ .transform((value) => (value === undefined ? undefined : normalizePhotoCompress(value))),
79
+ saveToGallery: z.boolean().optional(),
80
+ sound: z.boolean().optional(),
81
+ });
82
+ export type PhotoOptions = z.infer<typeof photoOptionsSchema>;
83
+
84
+ /**
85
+ * The REST response to a photo request. The capture happens out of band (the
86
+ * glasses upload to `uploadUrl`); `readUrl` is the presigned URL the finished
87
+ * photo will be readable at, returned up front so the client has it before the
88
+ * `photo.ready` push confirms the upload landed.
89
+ */
90
+ export const photoRequestResultSchema = z.object({
91
+ requestId: z.string(),
92
+ uploadUrl: z.string(),
93
+ readUrl: z.string(),
94
+ });
95
+ export type PhotoRequestResult = z.infer<typeof photoRequestResultSchema>;
96
+
97
+ /** `photo.ready` push payload: the capture+upload completed. */
98
+ export const photoReadyPayloadSchema = z.object({
99
+ requestId: z.string(),
100
+ readUrl: z.string(),
101
+ });
102
+ export type PhotoReady = z.infer<typeof photoReadyPayloadSchema>;
103
+
104
+ /** `photo.error` push payload: the capture+upload failed. */
105
+ export const photoErrorPayloadSchema = z.object({
106
+ requestId: z.string(),
107
+ reason: z.string(),
108
+ });
109
+ export type PhotoError = z.infer<typeof photoErrorPayloadSchema>;
110
+
111
+ // --- Managed stream ---------------------------------------------------------
112
+
113
+ /** A restream destination: re-publish the ingest to an external RTMP target. */
114
+ export const restreamDestinationSchema = z.union([
115
+ z.string(),
116
+ z.object({ url: z.string(), name: z.string().optional() }),
117
+ ]);
118
+ export type RestreamDestination = z.infer<typeof restreamDestinationSchema>;
119
+
120
+ export const streamOptionsSchema = z.object({
121
+ /** Region hint so the cloud provisions a nearby ingest endpoint. */
122
+ region: z.string().optional(),
123
+ /** External RTMP targets the provider re-publishes the ingest to. */
124
+ restreamDestinations: z.array(restreamDestinationSchema).optional(),
125
+ });
126
+ export type StreamOptions = z.infer<typeof streamOptionsSchema>;
127
+
128
+ /**
129
+ * A provisioned stream. `ingest` is where the device pushes frames; `playback`
130
+ * is where viewers watch. Both are left as open records because the provider
131
+ * (Cloudflare Stream by default) is swappable per region and its exact field
132
+ * shapes are not finalized. The Cloudflare provider populates ingest
133
+ * `{protocol, url, streamKey, rtmpUrl, srtUrl?, webrtcPublishUrl?}` (the last
134
+ * three ready-to-publish with credentials embedded) and playback
135
+ * `{hls, dash, webrtc?}`.
136
+ */
137
+ export const managedStreamSchema = z.object({
138
+ streamId: z.string(),
139
+ ingest: z.record(z.unknown()),
140
+ playback: z.record(z.unknown()),
141
+ });
142
+ export type ManagedStream = z.infer<typeof managedStreamSchema>;
143
+
144
+ /**
145
+ * `GET /api/camera/stream/:id` result: the provider's view of the ingest.
146
+ * `isConnected` is the portable signal (is the device's push arriving?);
147
+ * the rest is provider detail for debugging.
148
+ */
149
+ export const streamStatusResultSchema = z.object({
150
+ streamId: z.string(),
151
+ isConnected: z.boolean(),
152
+ state: z.string().nullable(),
153
+ connectedAt: z.string().optional(),
154
+ lastSeenAt: z.string().optional(),
155
+ reason: z.string().optional(),
156
+ });
157
+ export type StreamStatusResult = z.infer<typeof streamStatusResultSchema>;
package/src/control.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @fileoverview Control payloads: control.ping and control.pong.
3
+ *
4
+ * Mirrors docs/issues/002-cloud-runtime/protocol.md ("Control"). Liveness and
5
+ * RTT, separate from the WebSocket ping frame. Pure + isomorphic.
6
+ */
7
+ import { z } from "zod";
8
+
9
+ export const controlPingPayloadSchema = z.object({}).strict();
10
+ export type ControlPing = z.infer<typeof controlPingPayloadSchema>;
11
+
12
+ export const controlPongPayloadSchema = z.object({}).strict();
13
+ export type ControlPong = z.infer<typeof controlPongPayloadSchema>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @fileoverview The transport envelope shared by every WebSocket message.
3
+ *
4
+ * Mirrors docs/issues/002-cloud-runtime/protocol.md ("Envelope"). Pure +
5
+ * isomorphic: no server imports.
6
+ */
7
+ import { z } from "zod";
8
+
9
+ /** Protocol major. A mismatched major is rejected at the handshake. */
10
+ export const PROTOCOL_MAJOR = 2 as const;
11
+
12
+ /**
13
+ * Wraps a payload schema in the protocol envelope with a literal `type`, so a
14
+ * set of message schemas can be combined into a discriminated union on `type`.
15
+ *
16
+ * const connectionAck = envelope("connection.ack", connectionAckPayloadSchema)
17
+ */
18
+ export function envelope<Type extends string, Payload extends z.ZodTypeAny>(
19
+ type: Type,
20
+ payload: Payload,
21
+ ) {
22
+ return z.object({
23
+ v: z.literal(PROTOCOL_MAJOR),
24
+ type: z.literal(type),
25
+ // present only when a message needs request/ack correlation
26
+ id: z.string().optional(),
27
+ // epoch milliseconds (Unix time)
28
+ timestamp: z.number().int(),
29
+ payload,
30
+ });
31
+ }
32
+
33
+ /** The structural envelope, payload left open. Useful for a first-pass parse. */
34
+ export const envelopeSchema = z.object({
35
+ v: z.literal(PROTOCOL_MAJOR),
36
+ type: z.string(),
37
+ id: z.string().optional(),
38
+ timestamp: z.number().int(),
39
+ payload: z.unknown(),
40
+ });
41
+
42
+ export interface Envelope<T = unknown> {
43
+ v: typeof PROTOCOL_MAJOR;
44
+ type: string;
45
+ id?: string;
46
+ timestamp: number;
47
+ payload: T;
48
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @fileoverview The error payload and transport-level error codes.
3
+ *
4
+ * Mirrors docs/issues/002-cloud-runtime/protocol.md ("Errors"). Services may
5
+ * define additional codes for their own payloads (documented in the service
6
+ * doc). Pure + isomorphic.
7
+ */
8
+ import { z } from "zod";
9
+
10
+ /** Transport-level error codes shared by every runtime service. */
11
+ export const PROTOCOL_ERROR_CODES = {
12
+ AUTH_FAILED: { fatal: true }, // token missing or invalid
13
+ AUTH_EXPIRED: { fatal: true }, // token expired; client should refresh and reopen
14
+ UNSUPPORTED_VERSION: { fatal: true }, // protocol major mismatch
15
+ BAD_REQUEST: { fatal: false }, // malformed payload for a known type
16
+ UNKNOWN_TYPE: { fatal: false }, // unrecognized `type`
17
+ INTERNAL: { fatal: false }, // server-side failure
18
+ } as const;
19
+
20
+ export type ProtocolErrorCode = keyof typeof PROTOCOL_ERROR_CODES;
21
+
22
+ export const protocolErrorPayloadSchema = z.object({
23
+ code: z.string(), // a ProtocolErrorCode or a service-defined code
24
+ message: z.string(),
25
+ fatal: z.boolean(), // if true, the server closes the socket after sending
26
+ });
27
+ export type ProtocolError = z.infer<typeof protocolErrorPayloadSchema>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @fileoverview Handshake payloads: connection.init and connection.ack.
3
+ *
4
+ * Mirrors docs/issues/002-cloud-runtime/protocol.md ("Auth and handshake").
5
+ * Service-scoped config rides in scoped blocks; audio is the only one today.
6
+ * Pure + isomorphic: no server imports.
7
+ */
8
+ import { z } from "zod";
9
+ import { audioSubscriptionSchema } from "./audio";
10
+
11
+ // --- connection.init (client to cloud) --------------------------------------
12
+
13
+ export const connectionInitPayloadSchema = z.object({
14
+ // omitted if provided via the ?token= fallback
15
+ token: z.string().optional(),
16
+ // semver of the client protocol build, e.g. "2.0.0"
17
+ protocolVersion: z.string(),
18
+ client: z
19
+ .object({
20
+ platform: z.enum(["ios", "android"]),
21
+ appVersion: z.string().optional(),
22
+ })
23
+ .optional(),
24
+ // Service-scoped config blocks. Audio is the only one today.
25
+ audio: z
26
+ .object({
27
+ codec: z.enum(["lc3", "pcm"]),
28
+ sampleRate: z.number().int(), // e.g. 16000
29
+ // LC3 bitrate / frame size in bytes. Only meaningful when codec is
30
+ // "lc3"; omitted PCM sessions keep treating payload bytes as PCM.
31
+ frameSizeBytes: z.union([z.literal(20), z.literal(40), z.literal(60)]).optional(),
32
+ // Optional initial subscription set, seeded atomically with the session
33
+ // so audio that starts before the first REST update is not transcribed
34
+ // with an empty set.
35
+ initialSubscriptions: z.array(audioSubscriptionSchema).optional(),
36
+ })
37
+ .optional(),
38
+ });
39
+ export type ConnectionInit = z.infer<typeof connectionInitPayloadSchema>;
40
+
41
+ // --- connection.ack (cloud to client) ---------------------------------------
42
+
43
+ export const connectionAckPayloadSchema = z.object({
44
+ sessionId: z.string(), // control-plane runtime session id
45
+ negotiatedVersion: z.string(),
46
+ // Audio session ingest coordinates (audio service, UDP path).
47
+ audio: z
48
+ .object({
49
+ sessionTag: z.number().int(), // u32 stamped into UDP audio frames
50
+ udp: z.object({ host: z.string(), port: z.number().int() }),
51
+ // Per-session key for encrypting UDP audio. Delivered here because the
52
+ // handshake is over the TLS WebSocket.
53
+ encryption: z.object({
54
+ algorithm: z.literal("xsalsa20-poly1305"), // NaCl secretbox
55
+ key: z.string(), // base64, 32 bytes
56
+ }),
57
+ })
58
+ .optional(),
59
+ });
60
+ export type ConnectionAck = z.infer<typeof connectionAckPayloadSchema>;
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @fileoverview `@mentra/cloud-runtime/protocol` public entrypoint.
3
+ *
4
+ * Pure, isomorphic transport types + zod validators for the Mentra Runtime
5
+ * protocol. Zero server imports (no `node:*`, no service code) so the client
6
+ * can import the contract without pulling in server code.
7
+ *
8
+ * See docs/issues/002-cloud-runtime/protocol.md.
9
+ */
10
+ export * from "./envelope";
11
+ export * from "./handshake";
12
+ export * from "./control";
13
+ export * from "./errors";
14
+ export * from "./messages";
15
+ export * from "./audio";
16
+ export * from "./camera";
17
+ export * from "./maps";
package/src/maps.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @fileoverview Canonical maps wire types: directions + reverse geocoding.
3
+ *
4
+ * zod schemas + inferred TS types for the maps service. Both flows are plain
5
+ * REST request/result (no WebSocket push, unlike camera), so nothing here is
6
+ * registered into the message union. Pure + isomorphic: no server imports, safe
7
+ * to bundle into the client.
8
+ *
9
+ * The vocabulary (TravelMode, ManeuverKind, avoidances, the route/step shapes)
10
+ * deliberately mirrors the mobile miniapp navigation SDK
11
+ * (mobile/modules/miniapp/src/modules/navigation.ts) so the contract is identical
12
+ * end-to-end: miniapp SDK <-> cloud-client <-> runtime. These are the PROVIDER-
13
+ * NEUTRAL types; each provider (Mapbox, Google) normalizes its own response into
14
+ * these inside its provider file, so this contract never leaks a vendor's shape.
15
+ *
16
+ * See docs/issues/002-cloud-runtime/maps/spec.md.
17
+ */
18
+ import { z } from "zod";
19
+
20
+ // --- Shared geometry --------------------------------------------------------
21
+
22
+ export const latLngSchema = z.object({
23
+ lat: z.number(),
24
+ lng: z.number(),
25
+ });
26
+ export type LatLng = z.infer<typeof latLngSchema>;
27
+
28
+ /** Travel profile. Providers map this to their own profile vocabulary. */
29
+ export const travelModeSchema = z.enum(["walking", "driving", "cycling", "two_wheeler"]);
30
+ export type TravelMode = z.infer<typeof travelModeSchema>;
31
+
32
+ /** Routing preferences. All flags default to false. */
33
+ export const routeAvoidancesSchema = z.object({
34
+ highways: z.boolean().optional(),
35
+ tolls: z.boolean().optional(),
36
+ ferries: z.boolean().optional(),
37
+ });
38
+ export type RouteAvoidances = z.infer<typeof routeAvoidancesSchema>;
39
+
40
+ /**
41
+ * Neutral maneuver vocabulary. Each provider maps its own maneuver type/modifier
42
+ * vocabulary into exactly these values; consumers never see vendor strings.
43
+ */
44
+ export const maneuverKindSchema = z.enum([
45
+ "STRAIGHT",
46
+ "CONTINUE",
47
+ "SLIGHT_LEFT",
48
+ "SLIGHT_RIGHT",
49
+ "TURN_LEFT",
50
+ "TURN_RIGHT",
51
+ "SHARP_LEFT",
52
+ "SHARP_RIGHT",
53
+ "U_TURN",
54
+ "NAME_CHANGE",
55
+ "DEPART",
56
+ "ARRIVE",
57
+ "CROSS_STREET",
58
+ ]);
59
+ export type ManeuverKind = z.infer<typeof maneuverKindSchema>;
60
+
61
+ // --- Directions -------------------------------------------------------------
62
+
63
+ export const directionsRequestSchema = z.object({
64
+ origin: latLngSchema,
65
+ /** Ordered stops; last is the final destination. Must have >= 1 entry. */
66
+ stops: z.array(latLngSchema).min(1),
67
+ /** Defaults to "driving" when omitted. */
68
+ mode: travelModeSchema.optional(),
69
+ avoid: routeAvoidancesSchema.optional(),
70
+ /**
71
+ * Total number of routes to return — the primary plus any alternates, provider
72
+ * permitting. 1 (the default) means the primary route only, no alternates; a
73
+ * value > 1 asks the provider for alternates. (Mirrors the prior on-device
74
+ * behavior: Mapbox `alternatives` is requested only when this exceeds 1.)
75
+ */
76
+ alternatives: z.number().int().positive().optional(),
77
+ });
78
+ export type DirectionsRequest = z.infer<typeof directionsRequestSchema>;
79
+
80
+ /** One step of a computed route. The maneuver ENDS the segment. */
81
+ export const routeStepSchema = z.object({
82
+ lat: z.number(),
83
+ lng: z.number(),
84
+ endLat: z.number(),
85
+ endLng: z.number(),
86
+ distanceMeters: z.number(),
87
+ maneuver: maneuverKindSchema.optional(),
88
+ /** Full instruction from the provider (e.g. "Turn left onto Fell St"). */
89
+ instruction: z.string().optional(),
90
+ /** Resolved road name. Prefer this over parsing `instruction`. */
91
+ road: z.string().nullable().optional(),
92
+ });
93
+ export type RouteStep = z.infer<typeof routeStepSchema>;
94
+
95
+ export const routeSchema = z.object({
96
+ points: z.array(latLngSchema),
97
+ totalDistanceMeters: z.number(),
98
+ totalDurationSeconds: z.number(),
99
+ summary: z.string().optional(),
100
+ steps: z.array(routeStepSchema).optional(),
101
+ });
102
+ export type Route = z.infer<typeof routeSchema>;
103
+
104
+ /** The REST response for `POST /api/maps/directions`. Primary route first. */
105
+ export const directionsResultSchema = z.object({
106
+ routes: z.array(routeSchema),
107
+ });
108
+ export type DirectionsResult = z.infer<typeof directionsResultSchema>;
109
+
110
+ // --- Reverse geocoding ------------------------------------------------------
111
+
112
+ export const reverseGeocodeRequestSchema = latLngSchema;
113
+ export type ReverseGeocodeRequest = z.infer<typeof reverseGeocodeRequestSchema>;
114
+
115
+ /**
116
+ * The REST response for `POST /api/maps/reverse-geocode`.
117
+ *
118
+ * - `road` — short street name (e.g. "Hayes Street"). Backs the pivot
119
+ * engine's road-name fallback.
120
+ * - `address` — full formatted street address with house number + locality
121
+ * (e.g. "369 Hayes Street, San Francisco, California 94102"). Backs the
122
+ * navigation miniapp's dropped-pin / POI-tap labels.
123
+ *
124
+ * Either field is null when nothing of that kind was found near the coordinate;
125
+ * that is a successful empty answer, not an error (an actual failure is a
126
+ * non-2xx response).
127
+ */
128
+ export const reverseGeocodeResultSchema = z.object({
129
+ road: z.string().nullable(),
130
+ address: z.string().nullable(),
131
+ });
132
+ export type ReverseGeocodeResult = z.infer<typeof reverseGeocodeResultSchema>;
133
+
134
+ // --- Place search (autocomplete + details) ----------------------------------
135
+ //
136
+ // A two-step "type-ahead then pick" flow, provider-neutral like the rest of this
137
+ // file. `placeAutocomplete` lists lightweight suggestions as the user types;
138
+ // `placeDetails` resolves the chosen suggestion to coordinates. A `sessionToken`
139
+ // threads BOTH calls so the provider can bill the keystrokes + the final detail
140
+ // fetch as ONE search session (Mapbox Search Box and Google Places both work
141
+ // this way). Callers create one token per "search box opening" and rotate it
142
+ // after a pick.
143
+
144
+ export const placeAutocompleteRequestSchema = z.object({
145
+ /** The user's partial query. Empty/whitespace yields no suggestions. */
146
+ query: z.string(),
147
+ /** Optional location bias — rank results near this coordinate. */
148
+ near: latLngSchema.optional(),
149
+ /** Opaque per-search-session token; the same value should go on `placeDetails`. */
150
+ sessionToken: z.string(),
151
+ });
152
+ export type PlaceAutocompleteRequest = z.infer<typeof placeAutocompleteRequestSchema>;
153
+
154
+ /** One type-ahead suggestion. `placeId` is fed back into `placeDetails`. */
155
+ export const placeSuggestionSchema = z.object({
156
+ placeId: z.string(),
157
+ /** Primary line, e.g. "Blue Bottle Coffee". */
158
+ mainText: z.string(),
159
+ /** Secondary line, e.g. "66 Mint St, San Francisco". Empty when none. */
160
+ secondaryText: z.string(),
161
+ });
162
+ export type PlaceSuggestion = z.infer<typeof placeSuggestionSchema>;
163
+
164
+ export const placeAutocompleteResultSchema = z.object({
165
+ suggestions: z.array(placeSuggestionSchema),
166
+ });
167
+ export type PlaceAutocompleteResult = z.infer<typeof placeAutocompleteResultSchema>;
168
+
169
+ export const placeDetailsRequestSchema = z.object({
170
+ /** A `placeId` returned by a prior `placeAutocomplete` suggestion. */
171
+ placeId: z.string(),
172
+ /** The same session token used for the autocomplete that produced this id. */
173
+ sessionToken: z.string(),
174
+ });
175
+ export type PlaceDetailsRequest = z.infer<typeof placeDetailsRequestSchema>;
176
+
177
+ /** The resolved place: a name, formatted address, and coordinates to route to. */
178
+ export const placeDetailsResultSchema = z.object({
179
+ placeId: z.string(),
180
+ name: z.string(),
181
+ address: z.string(),
182
+ lat: z.number(),
183
+ lng: z.number(),
184
+ });
185
+ export type PlaceDetailsResult = z.infer<typeof placeDetailsResultSchema>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @fileoverview The WebSocket message registry.
3
+ *
4
+ * Each transport `type` maps 1:1 to an enveloped zod schema; the schemas are
5
+ * combined into discriminated unions on `type` for parse-time validation on
6
+ * both ends. Transport-level types live in protocol.md; per-service push events
7
+ * (stream.transcript / stream.translation) are registered by the audio service
8
+ * doc but their schemas live here so the one union validates everything.
9
+ *
10
+ * Pure + isomorphic: no server imports.
11
+ */
12
+ import { z } from "zod";
13
+ import { envelope } from "./envelope";
14
+ import { connectionInitPayloadSchema, connectionAckPayloadSchema } from "./handshake";
15
+ import { controlPingPayloadSchema, controlPongPayloadSchema } from "./control";
16
+ import { protocolErrorPayloadSchema } from "./errors";
17
+ import { transcriptionDataSchema, translationDataSchema, udpLivenessAckPayloadSchema } from "./audio";
18
+ import { photoReadyPayloadSchema, photoErrorPayloadSchema } from "./camera";
19
+
20
+ // --- Per-type enveloped message schemas -------------------------------------
21
+
22
+ export const connectionInitMessage = envelope("connection.init", connectionInitPayloadSchema);
23
+ export const connectionAckMessage = envelope("connection.ack", connectionAckPayloadSchema);
24
+ export const controlPingMessage = envelope("control.ping", controlPingPayloadSchema);
25
+ export const controlPongMessage = envelope("control.pong", controlPongPayloadSchema);
26
+ export const errorMessage = envelope("error", protocolErrorPayloadSchema);
27
+
28
+ // Audio service push events (registered in audio/protocol.md).
29
+ export const streamTranscriptMessage = envelope("stream.transcript", transcriptionDataSchema);
30
+ export const streamTranslationMessage = envelope("stream.translation", translationDataSchema);
31
+ export const audioUdpLivenessAckMessage = envelope("audio.udp_liveness_ack", udpLivenessAckPayloadSchema);
32
+
33
+ // Camera service push events (registered in camera/spec.md).
34
+ export const photoReadyMessage = envelope("photo.ready", photoReadyPayloadSchema);
35
+ export const photoErrorMessage = envelope("photo.error", photoErrorPayloadSchema);
36
+
37
+ // --- Direction-scoped unions ------------------------------------------------
38
+
39
+ /** Messages the client sends to the cloud. */
40
+ export const clientToCloudMessage = z.discriminatedUnion("type", [
41
+ connectionInitMessage,
42
+ controlPingMessage,
43
+ controlPongMessage,
44
+ ]);
45
+ export type ClientToCloudMessage = z.infer<typeof clientToCloudMessage>;
46
+
47
+ /** Messages the cloud pushes to the client. */
48
+ export const cloudToClientMessage = z.discriminatedUnion("type", [
49
+ connectionAckMessage,
50
+ controlPingMessage,
51
+ controlPongMessage,
52
+ errorMessage,
53
+ streamTranscriptMessage,
54
+ streamTranslationMessage,
55
+ audioUdpLivenessAckMessage,
56
+ photoReadyMessage,
57
+ photoErrorMessage,
58
+ ]);
59
+ export type CloudToClientMessage = z.infer<typeof cloudToClientMessage>;
60
+
61
+ /** Every known message in either direction. */
62
+ export const anyMessage = z.discriminatedUnion("type", [
63
+ connectionInitMessage,
64
+ connectionAckMessage,
65
+ controlPingMessage,
66
+ controlPongMessage,
67
+ errorMessage,
68
+ streamTranscriptMessage,
69
+ streamTranslationMessage,
70
+ audioUdpLivenessAckMessage,
71
+ photoReadyMessage,
72
+ photoErrorMessage,
73
+ ]);
74
+ export type AnyMessage = z.infer<typeof anyMessage>;