Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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