Begin work on Expo update server
diff --git a/README.md b/README.md
index 7266ad7..41a913b 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,22 @@
 
 ## API
 
-Base URL: `https://aero.jottocraft.com/<product identity>/<version or channel name>`
+Base URL: `https://aero.jottocraft.com/<product identity>/<artifact type>`
 
-*Providing a channel name redirects to the latest version in that channel*
+*Providing a channel name serves the latest version in that channel*
 
-- Windows MSIX (only supports msixbundle, no optional or related packages)
-    - `/ms-appinstaller`
+
+### Artifact types
+
+- `msix` Windows MSIX (only supports msixbundle, no optional or related packages)
+    - `/ms-appinstaller` Redirects to a ms-appinstaller URI for installation via App Installer
     - `/<product identity>.appinstaller`
     - `/<product identity>.msixbundle`
     - `/Dependencies/<filename>`
+- `expo` Expo JavaScript OTA updates
+    - `/protocol1` Endpoint for requests made via the [Expo Updates v1 Protocol](https://docs.expo.dev/technical-specs/expo-updates-1), like u.expo.dev, this also optionally accepts runtime-version, channel-name, and platform via query parameters
+    - `/assets/<asset key>` Gets an asset
+    - `/<manifest ID>` Gets a specific update by manifest version
 - Win32 Squirrel (soon)
 - Android APK (soon)
 
@@ -25,6 +32,8 @@
     - Upload the deployment folder to `/<product identity>/msix/<deployment folder>` in R2
     - Create a release in Sanity and set the folder parameter to the name of the uploaded deployment folder
     - Profit
+- Expo
+    - Drag all assets and manifests into `/<product identity>/expo`. Assets should be named after their key (with no extension), and manifests should be named in the format `<manifest ID>.json`.
 
 
 Copyright (c) jottocraft 2023
\ No newline at end of file
diff --git a/src/deployments/expo.ts b/src/deployments/expo.ts
new file mode 100644
index 0000000..739e2d3
--- /dev/null
+++ b/src/deployments/expo.ts
@@ -0,0 +1,35 @@
+// Aero
+// Copyright (c) jottocraft 2023
+
+import { isSafe } from "../util/isSafe";
+import { serveR2 } from "../util/r2";
+
+export function expoRouter(request: Request, env: Env, ctx: AeroCtx, product: string, pathname: string): Promise<Response> {
+    const [, resource, key] = pathname.match(/\/(.+?)\/(.+?)(?:$|\/)/) ?? [];
+
+    switch (resource) {
+        case "protocol1": return serveExpoProtocol1(request, env, ctx, product, pathname);
+        case "assets": return serveExpoAsset(request, env, ctx, product, key);
+        default: return serveExpoManifest(request, env, ctx, product, resource);
+    }
+}
+
+async function serveExpoProtocol1(request: Request, env: Env, ctx: AeroCtx, product: string, pathname: string) {
+    const url = new URL(request.url);
+
+    const platform = request.headers.get("Expo-Platform") ?? url.searchParams.get("platform");
+    const runtime = request.headers.get("Expo-Runtime-Version") ?? url.searchParams.get("runtime-version");
+    const channel = request.headers.get("Expo-Channel-Name") ?? url.searchParams.get("channel-name");
+
+    return new Response(null, { status: 501 });
+}
+
+async function serveExpoAsset(request: Request, env: Env, ctx: AeroCtx, product: string, key: string) {
+    if (!isSafe(key)) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=400");
+    return serveR2(request, env, `${product}/expo/${key}`);
+}
+
+async function serveExpoManifest(request: Request, env: Env, ctx: AeroCtx, product: string, manifestID: string) {
+    if (!isSafe(manifestID)) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=400");
+    return serveR2(request, env, `${product}/expo/${manifestID}.json`);
+}
\ No newline at end of file
diff --git a/src/deployments/msix.tsx b/src/deployments/msix.tsx
index 49e1d4c..36d7564 100644
--- a/src/deployments/msix.tsx
+++ b/src/deployments/msix.tsx
@@ -19,7 +19,40 @@
 const OnLaunch = (props: any) => React.createElement("OnLaunch", props);
 const AutomaticBackgroundTask = (props: any) => React.createElement("AutomaticBackgroundTask", props);
 
-export async function serveAppinstaller(reqData: RequestData) {
+const SAFE_CHARS = /^[A-Za-z0-9-_\.]*$/;
+
+export function msixRouter(request: Request, env: Env, ctx: AeroCtx, product: string, pathname: string): Promise<Response> {
+    const url = new URL(request.url);
+    const [, versionOrChannelName, fileFolder, arg] = pathname.match(/\/(.+?)\/(.+?)(?:$|\/)(.*)/) ?? [];
+    const reg = url.searchParams.get("reg") ?? undefined;
+
+    if (!versionOrChannelName || !fileFolder) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
+    if (!SAFE_CHARS.test(product) || !SAFE_CHARS.test(versionOrChannelName)) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=400");
+
+    const requestData: MSIXRequestData = {
+        request,
+        url,
+        env,
+        ctx,
+        product,
+        versionOrChannelName,
+        artifact: fileFolder,
+        arg,
+        reg
+    };
+
+    switch (fileFolder) {
+        // MSIX
+        case "ms-appinstaller": return serveMsAppInstaller(requestData);
+        case product + ".appinstaller": return serveAppinstaller(requestData);
+        case product + ".msixbundle": return serveMsixBundle(requestData);
+        case "Dependencies": return serveDependency(requestData);
+
+        default: return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
+    }
+}
+
+export async function serveAppinstaller(reqData: MSIXRequestData) {
     const { 
         url, env,
         versionOrChannelName, reg
@@ -90,7 +123,7 @@
     });
 }
 
-export async function serveMsixBundle(reqData: RequestData) {
+export async function serveMsixBundle(reqData: MSIXRequestData) {
     const { env } = reqData;
     const release = await getRelease(reqData, true);
     if (release instanceof Response) return release;
@@ -98,10 +131,10 @@
     const deployment = release.deployments.find(deployment => deployment._type === "msix");
     if (!deployment || (deployment._type !== "msix")) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 
-    return serveR2(`${release.product.identity}/msix/${deployment.folder}/${release.product.identity}.msixbundle`, reqData);
+    return serveR2(reqData.request, reqData.env, `${release.product.identity}/msix/${deployment.folder}/${release.product.identity}.msixbundle`);
 }
 
-export async function serveDependency(reqData: RequestData) {
+export async function serveDependency(reqData: MSIXRequestData) {
     const { env, arg } = reqData;
     const release = await getRelease(reqData, true);
     if (release instanceof Response) return release;
@@ -109,10 +142,10 @@
     const deployment = release.deployments.find(deployment => deployment._type === "msix");
     if (!deployment || (deployment._type !== "msix")) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 
-    return serveR2(`${release.product.identity}/msix/${deployment.folder}/Dependencies/${arg}`, reqData);
+    return serveR2(reqData.request, reqData.env, `${release.product.identity}/msix/${deployment.folder}/Dependencies/${arg}`);
 }
 
-export async function serveMsAppInstaller(reqData: RequestData) {
+export async function serveMsAppInstaller(reqData: MSIXRequestData) {
     const { url, env, reg: requestedReg, versionOrChannelName } = reqData;
     const release = await getRelease(reqData, false);
     if (release instanceof Response) return release;
diff --git a/src/globals.d.ts b/src/globals.d.ts
index a3daea9..420c553 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -1,17 +1,20 @@
 // Aero
 // Copyright (c) jottocraft 2023
 
-declare type RequestData = {
+declare type MSIXRequestData = {
 	request: Request;
 	url: URL;
 	env: Env;
-	ctx: ExecutionContext;
+	ctx: AeroCtx;
 	product: string;
 	versionOrChannelName: string;
 	artifact: string;
 	arg?: string;
 	reg?: string;
-	headers: HeadersRef;
+};
+
+declare type AeroCtx = ExecutionContext & {
+	headersPatch: HeadersRef;
 };
 
 declare type Env = {
diff --git a/src/index.ts b/src/index.ts
index 7667b95..7d1999b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,49 +1,34 @@
 // Aero
 // Copyright (c) jottocraft 2023
 
-import { serveAppinstaller, serveDependency, serveMsAppInstaller, serveMsixBundle } from "./deployments/msix";
 import { applyModifications } from "@jottocraft/ratatoskr-sdk/lib/headers";
 
-const SAFE_CHARS = /^[A-Za-z0-9-_\.]*$/;
+import { expoRouter } from "./deployments/expo";
+import { msixRouter } from "./deployments/msix";
+import { isSafe } from "./util/isSafe";
 
 export default {
-	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
-		const headers: HeadersRef = { current: null };
+	async fetch(request: Request, env: Env, cfCtx: ExecutionContext): Promise<Response> {
+		const headersPatch: HeadersRef = { current: null };
+
+		const ctx = cfCtx as AeroCtx;
+		ctx.headersPatch = headersPatch;
 
 		const response = await (async () => {
 			const url = new URL(request.url);
-			const [, product, versionOrChannelName, artifact, arg] = url.pathname.match(/\/(.+?)\/(.+?)\/(.+?)(?:$|\/)(.*)/) ?? [];
-			const reg = url.searchParams.get("reg") ?? undefined;
+			const [, product, artifact, pathname] = url.pathname.match(/\/(.+?)\/(.+?)(\/.*)/) ?? [];
 		
-			if (!product || !versionOrChannelName || !artifact) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
-			if (!SAFE_CHARS.test(product) || !SAFE_CHARS.test(versionOrChannelName)) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=400");
+			if (!product || !artifact || !isSafe(product)) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 		
-			const requestData: RequestData = {
-				request,
-				url,
-				env,
-				ctx,
-				product,
-				versionOrChannelName,
-				artifact,
-				arg,
-				reg,
-				headers
-			};
-
 			switch (artifact) {
-				// MSIX
-				case "ms-appinstaller": return serveMsAppInstaller(requestData);
-				case product + ".appinstaller": return serveAppinstaller(requestData);
-				case product + ".msixbundle": return serveMsixBundle(requestData);
-				case "Dependencies": return serveDependency(requestData);
-
+				case "msix": return msixRouter(request, env, ctx, product, pathname);
+				case "expo": return expoRouter(request, env, ctx, product, pathname);
 				default: return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 			}
 		})();
 
-		const modifiable = new Response(response.body, response);;
-		if (headers.current) applyModifications(modifiable.headers, headers.current);
+		const modifiable = new Response(response.body, response);
+		if (headersPatch.current) applyModifications(modifiable.headers, headersPatch.current);
 
 		return modifiable;
 	}
diff --git a/src/releases.ts b/src/releases.ts
index 103f761..a4bc6d4 100644
--- a/src/releases.ts
+++ b/src/releases.ts
@@ -18,9 +18,9 @@
     return data;
 }
 
-export async function getRelease(reqData: RequestData, requireReg: boolean): Promise<Release | Response> {
+export async function getRelease(reqData: MSIXRequestData, requireReg: boolean): Promise<Release | Response> {
     const { 
-        request, env, ctx, headers,
+        request, env, ctx,
         product, versionOrChannelName, reg 
     } = reqData;
 
@@ -63,7 +63,7 @@
             if (!registration.skus.find(sku => skuRequirement.includes(sku))) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=403");
         } else if (!requireReg) {
             const licenses = await ratatoskr.getLicenses();
-            if (licenses.headers) headers.current = licenses.headers;
+            if (licenses.headers) ctx.headersPatch.current = licenses.headers;
             if (!licenses.success) {
                 const sidURL = new URL("https://id.jottocraft.com/sid");
                 sidURL.searchParams.set("rp", request.url);
diff --git a/src/util/isSafe.ts b/src/util/isSafe.ts
new file mode 100644
index 0000000..cf38c38
--- /dev/null
+++ b/src/util/isSafe.ts
@@ -0,0 +1,6 @@
+// Aero
+// Copyright (c) jottocraft 2023
+
+export function isSafe(str: string) {
+    return /^[A-Za-z0-9-_\.]*$/.test(str);
+}
\ No newline at end of file
diff --git a/src/util/r2.ts b/src/util/r2.ts
index 37e9047..ba4d432 100644
--- a/src/util/r2.ts
+++ b/src/util/r2.ts
@@ -5,12 +5,10 @@
 // hits R2 HARD with Range-d requests when installing (I'm talking 300+ REQUESTS PER MINUTE from a single client...)
 // MAKE SURE RANGED REQUESTS WORK AND ARE EFFICIENT!!! OTHERWISE INFRA WILL NOT BE HAPPY!!!
 
-export async function serveR2(key: string, reqData: RequestData) {
-    const { request, env } = reqData;
-
+export async function serveR2(request: Request, env: Env, key: string) {
     if (request.method === "HEAD") {
         const object = await env.ARTIFACTS.head(key);
-        if (object === null) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=503");
+        if (object === null) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 
         const headers = new Headers();
         object.writeHttpMetadata(headers);
@@ -29,7 +27,7 @@
         onlyIf: request.headers
     }) as (R2ObjectBody | null);
 
-    if (object === null) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=503");
+    if (object === null) return env.HTTP_STATUS_PAGES.fetch("https://http-status-pages.jottocraft.com/?status=404");
 
     const headers = new Headers();
     object.writeHttpMetadata(headers);