Skip to content

Commit

Permalink
Rebase fresh (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
lucis authored Aug 21, 2023
1 parent fb6d117 commit 34ac8c4
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 119 deletions.
92 changes: 58 additions & 34 deletions src/build/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
regexpEscape,
toFileUrl,
} from "./deps.ts";
import { Builder, BuildSnapshot } from "./mod.ts";
import { getDependencies, saveSnapshot } from "./kv.ts";
import { getFile } from "./kvfs.ts";
import { Builder } from "./mod.ts";

export interface EsbuildBuilderOptions {
/** The build ID. */
Expand All @@ -29,12 +31,58 @@ export interface JSXConfig {

export class EsbuildBuilder implements Builder {
#options: EsbuildBuilderOptions;
#files: Map<string, Uint8Array>;
#dependencies: Map<string, string[]> | null;
#build: Promise<void> | null;

constructor(options: EsbuildBuilderOptions) {
this.#options = options;
this.#files = new Map<string, Uint8Array>();
this.#dependencies = null;
this.#build = null;
}

async read(path: string) {
const content = this.#files.get(path) || await getFile(path);

if (content) return content;

if (!this.#build) {
this.#build = this.build();

this.#build
.then(() => saveSnapshot(this.#files, this.#dependencies!))
.catch((error) => console.error(error));
}

await this.#build;

return this.#files.get(path) || null;
}

// Lazy load dependencies from KV to avoid blocking first render
dependencies(path: string): string[] {
const deps = this.#dependencies?.get(path);

if (!this.#dependencies) {
this.#dependencies = new Map();

getDependencies().then((d) => {
// A build happened while we were fetching deps.
// It will fill deps for us with a fresh deps array
if (this.#build instanceof Promise) {
return;
} else if (d) {
this.#dependencies = d;
}
}).catch((error) => console.error(error));
}

return deps ?? [];
}

async build(): Promise<EsbuildSnapshot> {
async build(): Promise<void> {
const start = performance.now();
const opts = this.#options;
try {
await initEsbuild();
Expand All @@ -55,7 +103,7 @@ export class EsbuildBuilder implements Builder {
entryPoints: opts.entrypoints,

platform: "browser",
target: ["chrome99", "firefox99", "safari15"],
target: ["chrome99", "firefox99", "safari12"],

format: "esm",
bundle: true,
Expand All @@ -78,14 +126,17 @@ export class EsbuildBuilder implements Builder {
],
});

const files = new Map<string, Uint8Array>();
const dependencies = new Map<string, string[]>();
const dur = (performance.now() - start) / 1e3;
console.info(` 📦 Fresh bundle: ${dur.toFixed(2)}s`);

this.#files = new Map<string, Uint8Array>();
this.#dependencies = new Map<string, string[]>();

const absWorkingDirLen = toFileUrl(absWorkingDir).href.length + 1;

for (const file of bundle.outputFiles) {
const path = toFileUrl(file.path).href.slice(absWorkingDirLen);
files.set(path, file.contents);
this.#files.set(path, file.contents);
}

const metaOutputs = new Map(Object.entries(bundle.metafile.outputs));
Expand All @@ -94,10 +145,8 @@ export class EsbuildBuilder implements Builder {
const imports = entry.imports
.filter(({ kind }) => kind === "import-statement")
.map(({ path }) => path);
dependencies.set(path, imports);
this.#dependencies.set(path, imports);
}

return new EsbuildSnapshot(files, dependencies);
} finally {
stopEsbuild();
}
Expand Down Expand Up @@ -149,28 +198,3 @@ function buildIdPlugin(buildId: string): esbuildTypes.Plugin {
},
};
}

export class EsbuildSnapshot implements BuildSnapshot {
#files: Map<string, Uint8Array>;
#dependencies: Map<string, string[]>;

constructor(
files: Map<string, Uint8Array>,
dependencies: Map<string, string[]>,
) {
this.#files = files;
this.#dependencies = dependencies;
}

get paths(): string[] {
return Array.from(this.#files.keys());
}

read(path: string): Uint8Array | null {
return this.#files.get(path) ?? null;
}

dependencies(path: string): string[] {
return this.#dependencies.get(path) ?? [];
}
}
64 changes: 64 additions & 0 deletions src/build/kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getFile, housekeep, isSupported, saveFile } from "./kvfs.ts";

const IS_CHUNK = /\/chunk-[a-zA-Z0-9]*.js/;
const DEPENDENCIES_SNAP = "dependencies.snap.json";

export const getDependencies = async () => {
const deps = await getFile(DEPENDENCIES_SNAP);

if (!deps) {
return null;
}

const json = await new Response(deps).json();
return new Map<string, string[]>(json);
};

export const saveDependencies = (deps: Map<string, string[]>) =>
saveFile(
DEPENDENCIES_SNAP,
new TextEncoder().encode(
JSON.stringify([...deps.entries()]),
),
);

export const saveSnapshot = async (
filesystem: Map<string, Uint8Array>,
dependencies: Map<string, string[]>,
) => {
if (!isSupported()) return;

// We need to save chunks first, islands/plugins last so we address esm.sh build instabilities
const chunksFirst = [...filesystem.keys()].sort((a, b) => {
const aIsChunk = IS_CHUNK.test(a);
const bIsChunk = IS_CHUNK.test(b);
const cmp = a > b ? 1 : a < b ? -1 : 0;
return aIsChunk && bIsChunk ? cmp : aIsChunk ? -10 : bIsChunk ? 10 : cmp;
});

let start = performance.now();
for (const path of chunksFirst) {
const content = filesystem.get(path);

if (content instanceof ReadableStream) {
console.info("streams are not yet supported on KVFS");
return;
}

if (content) await saveFile(path, content);
}

const deps = new Map<string, string[]>();
for (const dep of chunksFirst) {
deps.set(dep, dependencies.get(dep)!);
}
await saveDependencies(deps);

let dur = (performance.now() - start) / 1e3;
console.log(` 💾 Save bundle to Deno.KV: ${dur.toFixed(2)}s`);

start = performance.now();
await housekeep();
dur = (performance.now() - start) / 1e3;
console.log(` 🧹 Housekeep Deno.KV: ${dur.toFixed(2)}s`);
};
70 changes: 70 additions & 0 deletions src/build/kvfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { BUILD_ID } from "../server/build_id.ts";

const CHUNKSIZE = 65536;
const NAMESPACE = ["_frsh", "js", BUILD_ID];

// @ts-ignore as `Deno.openKv` is still unstable.
const kv = await Deno.openKv?.().catch((e) => {
console.error(e);

return null;
});

export const isSupported = () => kv != null;

export const getFile = async (file: string) => {
if (!isSupported()) return null;

const filepath = [...NAMESPACE, file];
const metadata = await kv!.get(filepath).catch(() => null);

if (metadata?.versionstamp == null) {
return null;
}

console.log(` 🚣 Streaming from Deno.KV ${file}`);

return new ReadableStream<Uint8Array>({
start: async (sink) => {
for await (const chunk of kv!.list({ prefix: filepath })) {
sink.enqueue(chunk.value as Uint8Array);
}
sink.close();
},
});
};

export const saveFile = async (file: string, content: Uint8Array) => {
if (!isSupported()) return null;

const filepath = [...NAMESPACE, file];
const metadata = await kv!.get(filepath);

// Current limitation: As of May 2023, KV Transactions only support a maximum of 10 operations.
let transaction = kv!.atomic();
let chunks = 0;
for (; chunks * CHUNKSIZE < content.length; chunks++) {
transaction = transaction.set(
[...filepath, chunks],
content.slice(chunks * CHUNKSIZE, (chunks + 1) * CHUNKSIZE),
);
}
const result = await transaction
.set(filepath, chunks)
.check(metadata)
.commit();

return result.ok;
};

export const housekeep = async () => {
if (!isSupported()) return null;

for await (
const item of kv!.list({ prefix: ["_frsh", "js"] })
) {
if (item.key.includes(BUILD_ID)) continue;

await kv!.delete(item.key);
}
};
10 changes: 2 additions & 8 deletions src/build/mod.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
export {
EsbuildBuilder,
type EsbuildBuilderOptions,
EsbuildSnapshot,
type JSXConfig,
} from "./esbuild.ts";
export interface Builder {
build(): Promise<BuildSnapshot>;
}

export interface BuildSnapshot {
/** The list of files contained in this snapshot, not prefixed by a slash. */
readonly paths: string[];
build(): Promise<void>;

/** For a given file, return it's contents.
* @throws If the file is not contained in this snapshot. */
read(path: string): ReadableStream<Uint8Array> | Uint8Array | null;
read(path: string): Promise<ReadableStream<Uint8Array> | Uint8Array | null>;

/** For a given entrypoint, return it's list of dependencies.
*
Expand Down
17 changes: 16 additions & 1 deletion src/runtime/entrypoints/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import {
} from "preact";
import { assetHashingHook } from "../utils.ts";

declare global {
interface Window {
scheduler?: {
postTask: (cb: () => void) => void;
};
}
}

function createRootFragment(
parent: Element,
replaceNode: Node | Node[],
Expand Down Expand Up @@ -52,6 +60,7 @@ export function revive(
// deno-lint-ignore no-explicit-any
props: any[],
) {
performance.mark("revive-start");
_walkInner(
islands,
props,
Expand All @@ -62,6 +71,7 @@ export function revive(
[h(Fragment, null)],
document.body,
);
performance.measure("revive", "revive-start");
}

function ServerComponent(
Expand Down Expand Up @@ -254,7 +264,10 @@ function _walkInner(
marker.endNode,
);

const _render = () =>
const _render = () => {
const tag = marker?.text?.substring("frsh-".length) ?? "";
const [id] = tag.split(":");
performance.mark(tag);
render(
vnode,
createRootFragment(
Expand All @@ -264,6 +277,8 @@ function _walkInner(
// deno-lint-ignore no-explicit-any
) as any as HTMLElement,
);
performance.measure(`hydrate: ${id}`, tag);
};

"scheduler" in window
// `scheduler.postTask` is async but that can easily
Expand Down
Loading

0 comments on commit 34ac8c4

Please sign in to comment.