Skip to content

Commit

Permalink
feat(workers-shared): Add Assets Server Worker behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
CarmenPopoviciu committed Aug 20, 2024
1 parent d0ecc6a commit e2dd4eb
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 29 deletions.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export type AssetEntry = {
path: string;
contentHash: string;
};

export class AssetsManifest {
private data: ArrayBuffer;

Expand Down
2 changes: 2 additions & 0 deletions packages/workers-shared/asset-server-worker/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// have the browser check in with the server to make sure its local cache is valid before using it
export const CACHE_CONTROL_BROWSER = "public, max-age=0, must-revalidate";
32 changes: 32 additions & 0 deletions packages/workers-shared/asset-server-worker/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type Environment = "production" | "local";

type Env = {
ASSETS_MANIFEST: ArrayBuffer;
ASSETS_KV_NAMESPACE: KVNamespace;
ENVIRONMENT: Environment;
};

type BodyEncoding = "manual" | "automatic";

interface ResponseInit {
encodeBody?: BodyEncoding;
}

interface KVNamespace {
getWithMetadata<Metadata = unknown>(
key: string,
type: "stream"
): KVValueWithMetadata<ReadableStream, Metadata>;
getWithMetadata<Metadata = unknown>(
key: string,
options?: {
type: "stream";
cacheTtl?: number;
}
): KVValueWithMetadata<ReadableStream, Metadata>;
}

type KVValueWithMetadata<Value, Metadata> = Promise<{
value: Value | null;
metadata: Metadata | null;
}>;
59 changes: 43 additions & 16 deletions packages/workers-shared/asset-server-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { AssetsManifest } from "./assets-manifest";
import { NoopAssetsManifest } from "./assets-manifest-no-op";

interface Env {
ASSETS_MANIFEST: ArrayBuffer;
ASSETS_KV_NAMESPACE: KVNamespace;
}
import {
InternalServerErrorResponse,
MethodNotAllowedResponse,
NotFoundResponse,
OkResponse,
} from "./responses";
import { getAdditionalHeaders, getMergedHeaders } from "./utils/headers";
import { getAssetWithMetadataFromKV } from "./utils/kv";

export default {
async fetch(request: Request, env: Env) {
if (!request.method.match(/^(get)$/i)) {
return new MethodNotAllowedResponse();
}

try {
return this.handleRequest(request, env);
} catch (err) {
return new InternalServerErrorResponse(err);
}
},

async handleRequest(request: Request, env: Env) {
const {
// ASSETS_MANIFEST is a pipeline binding to an ArrayBuffer containing the
// binary-encoded site manifest
Expand All @@ -19,20 +33,33 @@ export default {
} = env;

const url = new URL(request.url);
const { pathname } = url;
let { pathname } = url;

const isLocalDevContext = new Uint8Array(ASSETS_MANIFEST).at(0) === 1;
const assetsManifest = isLocalDevContext
? new NoopAssetsManifest()
: new AssetsManifest(ASSETS_MANIFEST);
const assetEntry = await assetsManifest.get(pathname);
const assetsManifest = new AssetsManifest(ASSETS_MANIFEST);

pathname = globalThis.decodeURIComponent(pathname);

const content = await ASSETS_KV_NAMESPACE.get(assetEntry);
const assetEntry = await assetsManifest.get(pathname);
if (!assetEntry) {
return new NotFoundResponse("Not Found :(");
}

if (!content) {
return new Response("Not Found", { status: 404 });
const assetResponse = await getAssetWithMetadataFromKV(
ASSETS_KV_NAMESPACE,
assetEntry
);
if (!assetResponse || !assetResponse.value) {
return new NotFoundResponse("Not Found :(");
}

return new Response(content);
const { value: assetContent, metadata: assetMetadata } = assetResponse;
const additionalHeaders = getAdditionalHeaders(
assetEntry,
assetMetadata,
request
);
const headers = getMergedHeaders(request.headers, additionalHeaders);

return new OkResponse(assetContent, { headers, encodeBody: "automatic" });
},
};
52 changes: 52 additions & 0 deletions packages/workers-shared/asset-server-worker/src/responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export class OkResponse extends Response {
constructor(body: BodyInit | null, init?: ResponseInit) {
super(body, {
...init,
status: 200,
});
}
}

export class NotFoundResponse extends Response {
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
super(body, {
...init,
status: 404,
statusText: "Not Found",
});
}
}

export class MethodNotAllowedResponse extends Response {
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
super(body, {
...init,
status: 405,
statusText: "Method Not Allowed",
});
}
}

export class InternalServerErrorResponse extends Response {
constructor(err: Error, init?: ResponseInit) {
let body: string | undefined = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((globalThis as any).DEBUG) {
body = `${err.message}\n\n${err.stack}`;
}

super(body, {
...init,
status: 500,
});
}
}

export class NotModifiedResponse extends Response {
constructor(...[_body, _init]: ConstructorParameters<typeof Response>) {
super(undefined, {
status: 304,
statusText: "Not Modified",
});
}
}
54 changes: 54 additions & 0 deletions packages/workers-shared/asset-server-worker/src/utils/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CACHE_CONTROL_BROWSER } from "../constants";
import type { AssetMetadata } from "./kv";

/**
* Returns a Headers object that is the union of `existingHeaders`
* and `additionalHeaders`. Headers specified by `additionalHeaders`
* will override those specified by `existingHeaders`.
*
*/
export function getMergedHeaders(
existingHeaders: Headers,
additionalHeaders: Headers
) {
return new Headers({
// override existing headers
...Object.fromEntries(new Headers(existingHeaders).entries()),
...Object.fromEntries(new Headers(additionalHeaders).entries()),
});
}

/**
* Returns a Headers object that contains additional headers (to those
* present in the original request) that the Assets Server Worker
* should attach to its response.
*
*/
export function getAdditionalHeaders(
assetKey: string,
assetMetadata: AssetMetadata | null,
request: Request
) {
let contentType = assetMetadata?.contentType ?? "application/octet-stream";
if (contentType.startsWith("text/") && !contentType.includes("charset")) {
contentType = `${contentType}; charset=utf-8`;
}

const headers = new Headers({
"Access-Control-Allow-Origin": "*",
"Content-Type": contentType,
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
ETag: `"${assetKey}"`,
});

if (isCacheable(request)) {
headers.append("Cache-Control", CACHE_CONTROL_BROWSER);
}

return headers;
}

function isCacheable(request: Request) {
return !request.headers.has("authorization") && !request.headers.has("range");
}
31 changes: 31 additions & 0 deletions packages/workers-shared/asset-server-worker/src/utils/kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export type AssetMetadata = {
contentType: string;
};

export async function getAssetWithMetadataFromKV(
assetsKVNamespace: KVNamespace,
assetKey: string,
retries = 1
) {
let attempts = 0;

while (attempts < retries) {
try {
return await assetsKVNamespace.getWithMetadata<AssetMetadata>(assetKey, {
type: "stream",
cacheTtl: 31536000, // 1 year
});
} catch (err) {
if (attempts >= retries) {
throw new Error(
`Requested asset ${assetKey} could not be found in KV namespace.`
);
}

// Exponential backoff, 1 second first time, then 2 second, then 4 second etc.
await new Promise((resolvePromise) =>
setTimeout(resolvePromise, Math.pow(2, attempts++) * 1000)
);
}
}
}

0 comments on commit e2dd4eb

Please sign in to comment.