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);