diff --git a/src/build/esbuild.ts b/src/build/esbuild.ts index 66bbad17475..f8e05e1e855 100644 --- a/src/build/esbuild.ts +++ b/src/build/esbuild.ts @@ -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. */ @@ -29,12 +31,58 @@ export interface JSXConfig { export class EsbuildBuilder implements Builder { #options: EsbuildBuilderOptions; + #files: Map; + #dependencies: Map | null; + #build: Promise | null; constructor(options: EsbuildBuilderOptions) { this.#options = options; + this.#files = new Map(); + 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 { + async build(): Promise { + const start = performance.now(); const opts = this.#options; try { await initEsbuild(); @@ -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, @@ -78,14 +126,17 @@ export class EsbuildBuilder implements Builder { ], }); - const files = new Map(); - const dependencies = new Map(); + const dur = (performance.now() - start) / 1e3; + console.info(` ๐Ÿ“ฆ Fresh bundle: ${dur.toFixed(2)}s`); + + this.#files = new Map(); + this.#dependencies = new Map(); 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)); @@ -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(); } @@ -149,28 +198,3 @@ function buildIdPlugin(buildId: string): esbuildTypes.Plugin { }, }; } - -export class EsbuildSnapshot implements BuildSnapshot { - #files: Map; - #dependencies: Map; - - constructor( - files: Map, - dependencies: Map, - ) { - 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) ?? []; - } -} diff --git a/src/build/kv.ts b/src/build/kv.ts new file mode 100644 index 00000000000..213306e4357 --- /dev/null +++ b/src/build/kv.ts @@ -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(json); +}; + +export const saveDependencies = (deps: Map) => + saveFile( + DEPENDENCIES_SNAP, + new TextEncoder().encode( + JSON.stringify([...deps.entries()]), + ), + ); + +export const saveSnapshot = async ( + filesystem: Map, + dependencies: Map, +) => { + 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(); + 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`); +}; diff --git a/src/build/kvfs.ts b/src/build/kvfs.ts new file mode 100644 index 00000000000..3409b37c15e --- /dev/null +++ b/src/build/kvfs.ts @@ -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({ + 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); + } +}; diff --git a/src/build/mod.ts b/src/build/mod.ts index a23a58af742..38bb141631b 100644 --- a/src/build/mod.ts +++ b/src/build/mod.ts @@ -1,20 +1,14 @@ export { EsbuildBuilder, type EsbuildBuilderOptions, - EsbuildSnapshot, type JSXConfig, } from "./esbuild.ts"; export interface Builder { - build(): Promise; -} - -export interface BuildSnapshot { - /** The list of files contained in this snapshot, not prefixed by a slash. */ - readonly paths: string[]; + build(): Promise; /** For a given file, return it's contents. * @throws If the file is not contained in this snapshot. */ - read(path: string): ReadableStream | Uint8Array | null; + read(path: string): Promise | Uint8Array | null>; /** For a given entrypoint, return it's list of dependencies. * diff --git a/src/runtime/entrypoints/main.ts b/src/runtime/entrypoints/main.ts index 75555661999..8e944f36c1b 100644 --- a/src/runtime/entrypoints/main.ts +++ b/src/runtime/entrypoints/main.ts @@ -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[], @@ -52,6 +60,7 @@ export function revive( // deno-lint-ignore no-explicit-any props: any[], ) { + performance.mark("revive-start"); _walkInner( islands, props, @@ -62,6 +71,7 @@ export function revive( [h(Fragment, null)], document.body, ); + performance.measure("revive", "revive-start"); } function ServerComponent( @@ -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( @@ -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 diff --git a/src/server/context.ts b/src/server/context.ts index 9eb5b919c24..1e4d558ac9e 100644 --- a/src/server/context.ts +++ b/src/server/context.ts @@ -1,5 +1,4 @@ import { - colors, dirname, extname, fromFileUrl, @@ -48,13 +47,7 @@ import { SELF, } from "../runtime/csp.ts"; import { ASSET_CACHE_BUST_KEY, INTERNAL_PREFIX } from "../runtime/utils.ts"; -import { - Builder, - BuildSnapshot, - EsbuildBuilder, - EsbuildSnapshot, - JSXConfig, -} from "../build/mod.ts"; +import { Builder, EsbuildBuilder, JSXConfig } from "../build/mod.ts"; import { InternalRoute } from "./router.ts"; import { setAllIslands } from "./rendering/preact_hooks.ts"; @@ -101,7 +94,7 @@ export class ServerContext { #notFound: UnknownPage; #error: ErrorPage; #plugins: Plugin[]; - #builder: Builder | Promise | BuildSnapshot; + #builder: Builder; #routerOptions: RouterOptions; constructor( @@ -119,7 +112,6 @@ export class ServerContext { jsxConfig: JSXConfig, dev: boolean = isDevMode(), routerOptions: RouterOptions, - snapshot: BuildSnapshot | null = null, ) { this.#routes = routes; this.#islands = islands; @@ -132,7 +124,7 @@ export class ServerContext { this.#error = error; this.#plugins = plugins; this.#dev = dev; - this.#builder = snapshot ?? new EsbuildBuilder({ + this.#builder = new EsbuildBuilder({ buildID: BUILD_ID, entrypoints: collectEntrypoints(this.#dev, this.#islands, this.#plugins), configPath, @@ -162,38 +154,6 @@ export class ServerContext { } // Restore snapshot if available - let snapshot: BuildSnapshot | null = null; - // Load from snapshot if not explicitly requested not to - const loadFromSnapshot = !opts.skipSnapshot; - if (loadFromSnapshot) { - const snapshotDirPath = join(dirname(configPath), "_fresh"); - try { - if ((await Deno.stat(snapshotDirPath)).isDirectory) { - console.log( - `Using snapshot found at ${colors.cyan(snapshotDirPath)}`, - ); - - const snapshotPath = join(snapshotDirPath, "snapshot.json"); - const json = JSON.parse(await Deno.readTextFile(snapshotPath)); - const dependencies = new Map( - Object.entries(json), - ); - - const files = new Map(); - const names = Object.keys(json); - await Promise.all(names.map(async (name) => { - const filePath = join(snapshotDirPath, name); - files.set(name, await Deno.readFile(filePath)); - })); - - snapshot = new EsbuildSnapshot(files, dependencies); - } - } catch (err) { - if (!(err instanceof Deno.errors.NotFound)) { - throw err; - } - } - } config.compilerOptions ??= {}; @@ -452,7 +412,6 @@ export class ServerContext { jsxConfig, dev, opts.router ?? DEFAULT_ROUTER_OPTIONS, - snapshot, ); } @@ -505,28 +464,6 @@ export class ServerContext { }; } - #maybeBuildSnapshot(): BuildSnapshot | null { - if ("build" in this.#builder || this.#builder instanceof Promise) { - return null; - } - return this.#builder; - } - - async buildSnapshot() { - if ("build" in this.#builder) { - const builder = this.#builder; - this.#builder = builder.build(); - try { - const snapshot = await this.#builder; - this.#builder = snapshot; - } catch (err) { - this.#builder = builder; - throw err; - } - } - return this.#builder; - } - /** * Identify which middlewares should be applied for a request, * chain them and return a handler response @@ -712,11 +649,6 @@ export class ServerContext { // Tell renderer about all globally available islands setAllIslands(this.#islands); - const dependenciesFn = (path: string) => { - const snapshot = this.#maybeBuildSnapshot(); - return snapshot?.dependencies(path) ?? []; - }; - const renderNotFound = async ( req: Request, params: Record, @@ -746,7 +678,7 @@ export class ServerContext { app: this.#app, layouts, imports, - dependenciesFn, + dependenciesFn: (path) => this.#builder.dependencies(path), renderFn: this.#renderFn, url: new URL(req.url), params, @@ -800,7 +732,7 @@ export class ServerContext { app: this.#app, layouts, imports, - dependenciesFn, + dependenciesFn: (path) => this.#builder.dependencies(path), renderFn: this.#renderFn, url: new URL(req.url), params, @@ -988,8 +920,7 @@ export class ServerContext { */ #bundleAssetRoute = (): router.MatchHandler => { return async (_req, _ctx, params) => { - const snapshot = await this.buildSnapshot(); - const contents = snapshot.read(params.path); + const contents = await this.#builder.read(params.path); if (!contents) return new Response(null, { status: 404 }); const headers: Record = { diff --git a/src/server/rendering/fresh_tags.tsx b/src/server/rendering/fresh_tags.tsx index 56685511623..0a11e956b0f 100644 --- a/src/server/rendering/fresh_tags.tsx +++ b/src/server/rendering/fresh_tags.tsx @@ -119,7 +119,8 @@ export function renderFreshTags( `import * as ${island.name}_${island.exportName} from "${url}";`; islandRegistry += `${island.id}:${island.name}_${island.exportName},`; } - script += `revive({${islandRegistry}}, STATE[0]);`; + script += + `try { revive({${islandRegistry}}, STATE[0]); } catch(err) { console.log("revive err", err); };`; } // Append the inline script.