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 ad3a948
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 31 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)
);
}
}
}
2 changes: 1 addition & 1 deletion packages/workers-shared/asset-server-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
# to this file are persisted in wrangler as well, when necessary.
# (see packages/wrangler/src/dev/miniflare.ts -> buildMiniflareOptions())
##
name = "asset-server"
name = "asset-server-worker"
main = "src/index.ts"
compatibility_date = "2024-07-31"
4 changes: 3 additions & 1 deletion packages/wrangler/src/dev/miniflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,9 @@ export function buildMiniflareBindingOptions(config: MiniflareBindingsConfig): {
// Setup service bindings to external services
const serviceBindings: NonNullable<WorkerOptions["serviceBindings"]> = {
...config.serviceBindings,
...(config.experimentalAssets ? { ASSET_SERVER: "asset-server" } : {}),
...(config.experimentalAssets
? { ASSET_SERVER: "asset-server-worker" }
: {}),
};

const notFoundServices = new Set<string>();
Expand Down

0 comments on commit ad3a948

Please sign in to comment.