From 3211ff989658a4c00bd2198ed32b723d13e7a19c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 8 Nov 2024 16:52:22 -0800 Subject: [PATCH] fix: documentation site build (#1042) --- compute-file-server-cli/src/main.rs | 16 +- documentation/app/c-at-e-file-server.js | 342 ++++++++++++++++++++++++ documentation/app/package-lock.json | 13 +- documentation/app/package.json | 3 +- documentation/app/src/index.js | 2 +- types/kv-store.d.ts | 2 +- 6 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 documentation/app/c-at-e-file-server.js diff --git a/compute-file-server-cli/src/main.rs b/compute-file-server-cli/src/main.rs index d43ca6aa2d..a1481de62d 100644 --- a/compute-file-server-cli/src/main.rs +++ b/compute-file-server-cli/src/main.rs @@ -585,7 +585,10 @@ async fn clone_version_of_service( ..Default::default() }; - Ok(clone_service_version(&mut cfg, params).await?.number.unwrap()) + Ok(clone_service_version(&mut cfg, params) + .await? + .number + .unwrap()) } async fn activate_version_of_service( @@ -607,7 +610,10 @@ async fn activate_version_of_service( ..Default::default() }; - Ok(activate_service_version(&mut cfg, params).await?.number.unwrap()) + Ok(activate_service_version(&mut cfg, params) + .await? + .number + .unwrap()) } fn cli() -> Command { @@ -735,7 +741,8 @@ async fn upload(sub_matches: &clap::ArgMatches) -> Result<(), Box("path") .expect("required in clap"); - let entries = WalkDir::new(path).follow_links(true) + let entries = WalkDir::new(path) + .follow_links(true) .into_iter() .filter_map(Result::ok) .filter(|e| !e.file_type().is_dir()) @@ -868,7 +875,8 @@ async fn local(sub_matches: &clap::ArgMatches) -> Result<(), Box("toml") .expect("required in clap"); - let entries = WalkDir::new(path).follow_links(true) + let entries = WalkDir::new(path) + .follow_links(true) .into_iter() .filter_map(Result::ok) .filter(|e| !e.file_type().is_dir()) diff --git a/documentation/app/c-at-e-file-server.js b/documentation/app/c-at-e-file-server.js new file mode 100644 index 0000000000..60b1ff4e56 --- /dev/null +++ b/documentation/app/c-at-e-file-server.js @@ -0,0 +1,342 @@ +import parseRange from 'range-parser' + +/** + * Attempt to locate the requested resource from a Fastly Object-Store, + * If the request is a GET or HEAD request and a resource was found in the Object-Store, this will return a `Response`. + * If request is not GET or HEAD, or no resource was found in the Object-Store, this will return `null` + * @param {string} store_name The name of the Fastly Object-Store to search within. + * @param {Request} request The request to attempt to match against a resource within the Object-Store. + * @returns {Promise} Returns a `Response` if a resource was found, else returns `null`. + */ +export async function get(store_name, request) { + const isHeadRequest = request.method === 'HEAD' + // static files should only respond on HEAD and GET requests + if (!isHeadRequest && request.method !== 'GET') { + return null + } + + // if path ends in / or does not have an extension + // then append /index.html to the end so we can serve a page + let path = new URL(request.url).pathname + if (path.endsWith('/')) { + path += 'index.html' + } else if (!path.includes('.')) { + path += '/index.html' + } + + const metadataPath = path + '__metadata__' + + let metadata = await (new KVStore(store_name)).get(metadataPath) + if (metadata == null) { + return null + } + metadata = await metadata.json(); + const responseHeaders = metadata; + responseHeaders['accept-ranges'] = 'bytes' + + const response = checkPreconditions(request, responseHeaders); + if (response) { + return response; + } + + const item = await (new KVStore(store_name)).get(path) + + if (item == null) { + return null + } + + let range = request.headers.get("range"); + if (range == null) { + return new Response(isHeadRequest ? null : item.body, { status: 200, headers: responseHeaders }) + } else { + return handleRangeRequest(item, range, responseHeaders, isHeadRequest) + } +} + +async function handleRangeRequest(item, range, headers, isHeadRequest) { + /** + * @type {Uint8Array} + */ + const itemBuffer = new Uint8Array(await item.arrayBuffer()) + const total = itemBuffer.byteLength + const subranges = parseRange(total, range) + + // -1 signals an unsatisfiable range + if (subranges == -1) { + headers['content-range'] = `bytes */${total}` + return new Response(null, { status: 416, headers }) + } + // -2 signals a malformed header string + if (subranges == -2) { + headers['content-length'] = String(total) + return new Response(isHeadRequest ? null : itemBuffer, { status: 200, headers }) + } + + if (subranges.length == 1) { + const { start, end } = subranges[0] + headers['content-range'] = `bytes ${start}-${end}/${total}` + headers['content-length'] = String(end - start + 1) + + return new Response(isHeadRequest ? null : itemBuffer.slice(start, end), { status: 206, headers }) + } else { + const mime = headers['Content-Type'] + headers['Content-Type'] = 'multipart/byteranges; boundary=3d6b6a416f9b5' + const enc = new TextEncoder(); + const boundaryString = '--3d6b6a416f9b5'; + const type = mime ? enc.encode(`Content-Type: ${mime}\n`) : null + const results = [] + let bufferLength = 0 + let boundary = enc.encode(`\n${boundaryString}\n`) + subranges.forEach(function ({ start, end }) { + { + bufferLength += boundary.byteLength + results.push(boundary) + } + if (type) { + results.push(type) + bufferLength += type.byteLength + } + { + let content_range = enc.encode(`Content-Range: bytes ${start}-${end}/${total}\n\n`) + bufferLength += content_range.byteLength + results.push(content_range) + } + { + let content = itemBuffer.slice(start, end) + bufferLength += content.byteLength + results.push(content) + } + }) + { + results.push(boundary) + bufferLength += boundary.byteLength + } + const body = concat(results, bufferLength) + const length = body.byteLength + headers['content-length'] = String(length) + return new Response(isHeadRequest ? null : body, { status: 206, headers }) + } +} + +function concat(views, length) { + console.log({length}) + const buf = new Uint8Array(length) + let offset = 0 + for (const v of views) { + const uint8view = new Uint8Array(v.buffer, v.byteOffset, v.byteLength) + buf.set(uint8view, offset) + offset += uint8view.byteLength + } + + return buf +} + +function checkPreconditions(request, responseHeaders) { + // https://httpwg.org/specs/rfc9110.html#rfc.section.13.2.2 + // A recipient cache or origin server MUST evaluate the request preconditions defined by this specification in the following order: + // 1. When recipient is the origin server and If-Match is present, evaluate the If-Match precondition: + // - if true, continue to step 3 + // - if false, respond 412 (Precondition Failed) unless it can be determined that the state-changing request has already succeeded (see Section 13.1.1) + let header = request.headers.get("if-match"); + if (typeof header === 'string') { + console.log("!ifMatch(responseHeaders, header)", !ifMatch(responseHeaders, header)); + if (!ifMatch(responseHeaders, header)) { + return new Response(null, { status: 412 }); + } + // } else { + // // 2. When recipient is the origin server, If-Match is not present, and If-Unmodified-Since is present, evaluate the If-Unmodified-Since precondition: + // // - if true, continue to step 3 + // // - if false, respond 412 (Precondition Failed) unless it can be determined that the state-changing request has already succeeded (see Section 13.1.4) + // header = request.headers.get("if-unmodified-since"); + // if (typeof header === 'string') { + // // console.log("!ifUnmodifiedSince(responseHeaders, header)", !ifUnmodifiedSince(responseHeaders, header)); + // if (!ifUnmodifiedSince(responseHeaders, header)) { + // return new Response(null, { status: 412 }); + // } + // } + } + + // 3. When If-None-Match is present, evaluate the If-None-Match precondition: + // - if true, continue to step 5 + // - if false for GET/HEAD, respond 304 (Not Modified) + // - if false for other methods, respond 412 (Precondition Failed) + header = request.headers.get("if-none-match"); + const method = request.method; + const get = "GET"; + const head = "HEAD"; + if (typeof header === 'string') { + // console.log("!ifNoneMatch(responseHeaders, header)", !ifNoneMatch(responseHeaders, header)); + if (!ifNoneMatch(responseHeaders, header)) { + if (method === get || method === head) { + return new Response(null, { status: 304, headers: responseHeaders }) + } + return new Response(null, { status: 412 }); + } + } else { + // 4. When the method is GET or HEAD, If-None-Match is not present, and If-Modified-Since is present, evaluate the If-Modified-Since precondition: + // - if true, continue to step 5 + // - if false, respond 304 (Not Modified) + if (method === get || method === head) { + header = request.headers.get("if-modified-since"); + if (typeof header === 'string') { + // console.log("!ifModifiedSince(responseHeaders, header)", !ifModifiedSince(responseHeaders, header)); + if (!ifModifiedSince(responseHeaders, header)) { + return new Response(null, { status: 304, headers: responseHeaders }) + } + } + } + } + + // 5. When the method is GET and both Range and If-Range are present, evaluate the If-Range precondition: + // - if true and the Range is applicable to the selected representation, respond 206 (Partial Content) + // - otherwise, ignore the Range header field and respond 200 (OK) + if (method === get) { + if (request.headers.get("range")) { + header = request.headers.get("if-range"); + if (typeof header === 'string') { + // console.log("!ifRange(responseHeaders, header)", !ifRange(responseHeaders, header)); + if (!ifRange(responseHeaders, header)) { + // We delete the range headers so that the `get` function will return the full body + request.headers.delete("range") + } + } + } + } + + // 6. Otherwise, + // - perform the requested method and respond according to its success or failure. + return null; +} + +function isWeak(etag) { + return etag.startsWith("W/\""); +} + +function isStrong(etag) { + return etag.startsWith("\""); +} + +function opaqueTag(etag) { + if (isWeak(etag)) { + return etag.substring(2); + } + return etag; +} +function weakMatch(a, b) { + // https://httpwg.org/specs/rfc9110.html#entity.tag.comparison + // two entity tags are equivalent if their opaque-tags match character-by-character, regardless of either or both being tagged as "weak". + return opaqueTag(a) === opaqueTag(b); +} + +function strongMatch(a, b) { + // https://httpwg.org/specs/rfc9110.html#entity.tag.comparison + // two entity tags are equivalent if both are not weak and their opaque-tags match character-by-character. + return isStrong(a) && isStrong(b) && a === b; +} + +function splitList(value) { + return value.split(",").map(s => s.trim()); +} + +// https://httpwg.org/specs/rfc9110.html#field.if-match +function ifMatch(validationFields, header) { + if (validationFields.ETag === undefined) { + return true; + } + + // 1. If the field value is "*", the condition is true if the origin server has a current representation for the target resource. + if (header === "*") { + if (validationFields.ETag !== undefined) { + return true; + } + } else { + // 2. If the field value is a list of entity tags, the condition is true if any of the listed tags match the entity tag of the selected representation. + // An origin server MUST use the strong comparison function when comparing entity tags for If-Match (Section 8.8.3.2), + // since the client intends this precondition to prevent the method from being applied if there have been any changes to the representation data. + if (splitList(header).some(etag => { + console.log(`strongMatch(${etag}, ${validationFields.ETag}) -- ${strongMatch(etag, validationFields.ETag)}`); + return strongMatch(etag, validationFields.ETag) + })) { + return true; + } + } + + // 3. Otherwise, the condition is false. + return false; +} + +// https://httpwg.org/specs/rfc9110.html#field.if-none-match +function ifNoneMatch(validationFields, header) { + // 1. If the field value is "*", the condition is false if the origin server has a current representation for the target resource. + if (header === "*") { + if (validationFields.ETag !== undefined) { + return false; + } + } else { + // 2. If the field value is a list of entity tags, the condition is false if one of the listed tags matches the entity tag of the selected representation. + // A recipient MUST use the weak comparison function when comparing entity tags for If-None-Match (Section 8.8.3.2), since weak entity tags can be used for cache validation even if there have been changes to the representation data. + if (splitList(header).some(etag => weakMatch(etag, validationFields.ETag))) { + return false; + } + } + + // 3. Otherwise, the condition is true. + return true; +} + +// https://httpwg.org/specs/rfc9110.html#field.if-modified-since +function ifModifiedSince(validationFields, header) { + // A recipient MUST ignore the If-Modified-Since header field if the received field value is not a valid HTTP-date, the field value has more than one member, or if the request method is neither GET nor HEAD. + const date = new Date(header); + if (isNaN(date)) { + return true; + } + + // 1. If the selected representation's last modification date is earlier or equal to the date provided in the field value, the condition is false. + if (new Date(validationFields["Last-Modified"]) <= date) { + return false; + } + // 2. Otherwise, the condition is true. + return true; +} + +// https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since +// function ifUnmodifiedSince(req, validationFields, header) { +// // A recipient MUST ignore the If-Unmodified-Since header field if the received field value is not a valid HTTP-date (including when the field value appears to be a list of dates). +// const date = new Date(header); +// if (isNaN(date)) { +// return true; +// } + +// // 1. If the selected representation's last modification date is earlier than or equal to the date provided in the field value, the condition is true. +// if (new Date(validationFields["Last-Modified"]) <= date) { +// return true; +// } +// // 2. Otherwise, the condition is false. +// return false; +// } + +// https://httpwg.org/specs/rfc9110.html#field.if-range +function ifRange(validationFields, header) { + const date = new Date(header); + console.log(new Date(validationFields["Last-Modified"]), date); + console.log(new Date(validationFields["Last-Modified"]).getTime() === date.getTime()); + if (!isNaN(date)) { + // To evaluate a received If-Range header field containing an HTTP-date: + // 1. If the HTTP-date validator provided is not a strong validator in the sense defined by Section 8.8.2.2, the condition is false. + // 2. If the HTTP-date validator provided exactly matches the Last-Modified field value for the selected representation, the condition is true. + if (new Date(validationFields["Last-Modified"]).getTime() === date.getTime()) { + return true; + } + // 3. Otherwise, the condition is false. + return false; + } else { + // To evaluate a received If-Range header field containing an entity-tag: + // 1. If the entity-tag validator provided exactly matches the ETag field value for the selected representation using the strong comparison function (Section 8.8.3.2), the condition is true. + if (strongMatch(header, validationFields.ETag)) { + return true; + } + // 2. Otherwise, the condition is false. + return false; + } +} diff --git a/documentation/app/package-lock.json b/documentation/app/package-lock.json index 0674ac110c..a0c60e20f0 100644 --- a/documentation/app/package-lock.json +++ b/documentation/app/package-lock.json @@ -6,7 +6,8 @@ "": { "license": "MIT", "devDependencies": { - "@fastly/js-compute": "^3" + "@fastly/js-compute": "^3", + "range-parser": "^1.2.1" } }, "node_modules/@bytecodealliance/componentize-js": { @@ -1815,6 +1816,16 @@ "dev": true, "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", diff --git a/documentation/app/package.json b/documentation/app/package.json index c7e2b0d633..be75404aa6 100644 --- a/documentation/app/package.json +++ b/documentation/app/package.json @@ -1,7 +1,8 @@ { "license": "MIT", "devDependencies": { - "@fastly/js-compute": "^3" + "@fastly/js-compute": "^3", + "range-parser": "^1.2.1" }, "type": "module", "scripts": { diff --git a/documentation/app/src/index.js b/documentation/app/src/index.js index 947e13a501..ac3285f1e3 100644 --- a/documentation/app/src/index.js +++ b/documentation/app/src/index.js @@ -1,7 +1,7 @@ /// /* eslint-env serviceworker */ -import { get } from "@jakechampion/c-at-e-file-server"; +import { get } from "../c-at-e-file-server.js"; import { env } from "fastly:env"; import { KVStore } from "fastly:kv-store"; diff --git a/types/kv-store.d.ts b/types/kv-store.d.ts index 41127c2cd9..ad670c7697 100644 --- a/types/kv-store.d.ts +++ b/types/kv-store.d.ts @@ -121,7 +121,7 @@ declare module 'fastly:kv-store' { list: string[]; /** * Pass this base64 cursor into a subsequent list call to obtain the next listing. - * + * * The cursor is *undefined* when the end of the list is reached. */ cursor: string | undefined;