Skip to content

Commit 34ac8c4

Browse files
authored
Rebase fresh (#14)
1 parent fb6d117 commit 34ac8c4

7 files changed

Lines changed: 218 additions & 119 deletions

File tree

src/build/esbuild.ts

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
regexpEscape,
88
toFileUrl,
99
} from "./deps.ts";
10-
import { Builder, BuildSnapshot } from "./mod.ts";
10+
import { getDependencies, saveSnapshot } from "./kv.ts";
11+
import { getFile } from "./kvfs.ts";
12+
import { Builder } from "./mod.ts";
1113

1214
export interface EsbuildBuilderOptions {
1315
/** The build ID. */
@@ -29,12 +31,58 @@ export interface JSXConfig {
2931

3032
export class EsbuildBuilder implements Builder {
3133
#options: EsbuildBuilderOptions;
34+
#files: Map<string, Uint8Array>;
35+
#dependencies: Map<string, string[]> | null;
36+
#build: Promise<void> | null;
3237

3338
constructor(options: EsbuildBuilderOptions) {
3439
this.#options = options;
40+
this.#files = new Map<string, Uint8Array>();
41+
this.#dependencies = null;
42+
this.#build = null;
43+
}
44+
45+
async read(path: string) {
46+
const content = this.#files.get(path) || await getFile(path);
47+
48+
if (content) return content;
49+
50+
if (!this.#build) {
51+
this.#build = this.build();
52+
53+
this.#build
54+
.then(() => saveSnapshot(this.#files, this.#dependencies!))
55+
.catch((error) => console.error(error));
56+
}
57+
58+
await this.#build;
59+
60+
return this.#files.get(path) || null;
61+
}
62+
63+
// Lazy load dependencies from KV to avoid blocking first render
64+
dependencies(path: string): string[] {
65+
const deps = this.#dependencies?.get(path);
66+
67+
if (!this.#dependencies) {
68+
this.#dependencies = new Map();
69+
70+
getDependencies().then((d) => {
71+
// A build happened while we were fetching deps.
72+
// It will fill deps for us with a fresh deps array
73+
if (this.#build instanceof Promise) {
74+
return;
75+
} else if (d) {
76+
this.#dependencies = d;
77+
}
78+
}).catch((error) => console.error(error));
79+
}
80+
81+
return deps ?? [];
3582
}
3683

37-
async build(): Promise<EsbuildSnapshot> {
84+
async build(): Promise<void> {
85+
const start = performance.now();
3886
const opts = this.#options;
3987
try {
4088
await initEsbuild();
@@ -55,7 +103,7 @@ export class EsbuildBuilder implements Builder {
55103
entryPoints: opts.entrypoints,
56104

57105
platform: "browser",
58-
target: ["chrome99", "firefox99", "safari15"],
106+
target: ["chrome99", "firefox99", "safari12"],
59107

60108
format: "esm",
61109
bundle: true,
@@ -78,14 +126,17 @@ export class EsbuildBuilder implements Builder {
78126
],
79127
});
80128

81-
const files = new Map<string, Uint8Array>();
82-
const dependencies = new Map<string, string[]>();
129+
const dur = (performance.now() - start) / 1e3;
130+
console.info(` 📦 Fresh bundle: ${dur.toFixed(2)}s`);
131+
132+
this.#files = new Map<string, Uint8Array>();
133+
this.#dependencies = new Map<string, string[]>();
83134

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

86137
for (const file of bundle.outputFiles) {
87138
const path = toFileUrl(file.path).href.slice(absWorkingDirLen);
88-
files.set(path, file.contents);
139+
this.#files.set(path, file.contents);
89140
}
90141

91142
const metaOutputs = new Map(Object.entries(bundle.metafile.outputs));
@@ -94,10 +145,8 @@ export class EsbuildBuilder implements Builder {
94145
const imports = entry.imports
95146
.filter(({ kind }) => kind === "import-statement")
96147
.map(({ path }) => path);
97-
dependencies.set(path, imports);
148+
this.#dependencies.set(path, imports);
98149
}
99-
100-
return new EsbuildSnapshot(files, dependencies);
101150
} finally {
102151
stopEsbuild();
103152
}
@@ -149,28 +198,3 @@ function buildIdPlugin(buildId: string): esbuildTypes.Plugin {
149198
},
150199
};
151200
}
152-
153-
export class EsbuildSnapshot implements BuildSnapshot {
154-
#files: Map<string, Uint8Array>;
155-
#dependencies: Map<string, string[]>;
156-
157-
constructor(
158-
files: Map<string, Uint8Array>,
159-
dependencies: Map<string, string[]>,
160-
) {
161-
this.#files = files;
162-
this.#dependencies = dependencies;
163-
}
164-
165-
get paths(): string[] {
166-
return Array.from(this.#files.keys());
167-
}
168-
169-
read(path: string): Uint8Array | null {
170-
return this.#files.get(path) ?? null;
171-
}
172-
173-
dependencies(path: string): string[] {
174-
return this.#dependencies.get(path) ?? [];
175-
}
176-
}

src/build/kv.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { getFile, housekeep, isSupported, saveFile } from "./kvfs.ts";
2+
3+
const IS_CHUNK = /\/chunk-[a-zA-Z0-9]*.js/;
4+
const DEPENDENCIES_SNAP = "dependencies.snap.json";
5+
6+
export const getDependencies = async () => {
7+
const deps = await getFile(DEPENDENCIES_SNAP);
8+
9+
if (!deps) {
10+
return null;
11+
}
12+
13+
const json = await new Response(deps).json();
14+
return new Map<string, string[]>(json);
15+
};
16+
17+
export const saveDependencies = (deps: Map<string, string[]>) =>
18+
saveFile(
19+
DEPENDENCIES_SNAP,
20+
new TextEncoder().encode(
21+
JSON.stringify([...deps.entries()]),
22+
),
23+
);
24+
25+
export const saveSnapshot = async (
26+
filesystem: Map<string, Uint8Array>,
27+
dependencies: Map<string, string[]>,
28+
) => {
29+
if (!isSupported()) return;
30+
31+
// We need to save chunks first, islands/plugins last so we address esm.sh build instabilities
32+
const chunksFirst = [...filesystem.keys()].sort((a, b) => {
33+
const aIsChunk = IS_CHUNK.test(a);
34+
const bIsChunk = IS_CHUNK.test(b);
35+
const cmp = a > b ? 1 : a < b ? -1 : 0;
36+
return aIsChunk && bIsChunk ? cmp : aIsChunk ? -10 : bIsChunk ? 10 : cmp;
37+
});
38+
39+
let start = performance.now();
40+
for (const path of chunksFirst) {
41+
const content = filesystem.get(path);
42+
43+
if (content instanceof ReadableStream) {
44+
console.info("streams are not yet supported on KVFS");
45+
return;
46+
}
47+
48+
if (content) await saveFile(path, content);
49+
}
50+
51+
const deps = new Map<string, string[]>();
52+
for (const dep of chunksFirst) {
53+
deps.set(dep, dependencies.get(dep)!);
54+
}
55+
await saveDependencies(deps);
56+
57+
let dur = (performance.now() - start) / 1e3;
58+
console.log(` 💾 Save bundle to Deno.KV: ${dur.toFixed(2)}s`);
59+
60+
start = performance.now();
61+
await housekeep();
62+
dur = (performance.now() - start) / 1e3;
63+
console.log(` 🧹 Housekeep Deno.KV: ${dur.toFixed(2)}s`);
64+
};

src/build/kvfs.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { BUILD_ID } from "../server/build_id.ts";
2+
3+
const CHUNKSIZE = 65536;
4+
const NAMESPACE = ["_frsh", "js", BUILD_ID];
5+
6+
// @ts-ignore as `Deno.openKv` is still unstable.
7+
const kv = await Deno.openKv?.().catch((e) => {
8+
console.error(e);
9+
10+
return null;
11+
});
12+
13+
export const isSupported = () => kv != null;
14+
15+
export const getFile = async (file: string) => {
16+
if (!isSupported()) return null;
17+
18+
const filepath = [...NAMESPACE, file];
19+
const metadata = await kv!.get(filepath).catch(() => null);
20+
21+
if (metadata?.versionstamp == null) {
22+
return null;
23+
}
24+
25+
console.log(` 🚣 Streaming from Deno.KV ${file}`);
26+
27+
return new ReadableStream<Uint8Array>({
28+
start: async (sink) => {
29+
for await (const chunk of kv!.list({ prefix: filepath })) {
30+
sink.enqueue(chunk.value as Uint8Array);
31+
}
32+
sink.close();
33+
},
34+
});
35+
};
36+
37+
export const saveFile = async (file: string, content: Uint8Array) => {
38+
if (!isSupported()) return null;
39+
40+
const filepath = [...NAMESPACE, file];
41+
const metadata = await kv!.get(filepath);
42+
43+
// Current limitation: As of May 2023, KV Transactions only support a maximum of 10 operations.
44+
let transaction = kv!.atomic();
45+
let chunks = 0;
46+
for (; chunks * CHUNKSIZE < content.length; chunks++) {
47+
transaction = transaction.set(
48+
[...filepath, chunks],
49+
content.slice(chunks * CHUNKSIZE, (chunks + 1) * CHUNKSIZE),
50+
);
51+
}
52+
const result = await transaction
53+
.set(filepath, chunks)
54+
.check(metadata)
55+
.commit();
56+
57+
return result.ok;
58+
};
59+
60+
export const housekeep = async () => {
61+
if (!isSupported()) return null;
62+
63+
for await (
64+
const item of kv!.list({ prefix: ["_frsh", "js"] })
65+
) {
66+
if (item.key.includes(BUILD_ID)) continue;
67+
68+
await kv!.delete(item.key);
69+
}
70+
};

src/build/mod.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
export {
22
EsbuildBuilder,
33
type EsbuildBuilderOptions,
4-
EsbuildSnapshot,
54
type JSXConfig,
65
} from "./esbuild.ts";
76
export interface Builder {
8-
build(): Promise<BuildSnapshot>;
9-
}
10-
11-
export interface BuildSnapshot {
12-
/** The list of files contained in this snapshot, not prefixed by a slash. */
13-
readonly paths: string[];
7+
build(): Promise<void>;
148

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

1913
/** For a given entrypoint, return it's list of dependencies.
2014
*

src/runtime/entrypoints/main.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import {
99
} from "preact";
1010
import { assetHashingHook } from "../utils.ts";
1111

12+
declare global {
13+
interface Window {
14+
scheduler?: {
15+
postTask: (cb: () => void) => void;
16+
};
17+
}
18+
}
19+
1220
function createRootFragment(
1321
parent: Element,
1422
replaceNode: Node | Node[],
@@ -52,6 +60,7 @@ export function revive(
5260
// deno-lint-ignore no-explicit-any
5361
props: any[],
5462
) {
63+
performance.mark("revive-start");
5564
_walkInner(
5665
islands,
5766
props,
@@ -62,6 +71,7 @@ export function revive(
6271
[h(Fragment, null)],
6372
document.body,
6473
);
74+
performance.measure("revive", "revive-start");
6575
}
6676

6777
function ServerComponent(
@@ -254,7 +264,10 @@ function _walkInner(
254264
marker.endNode,
255265
);
256266

257-
const _render = () =>
267+
const _render = () => {
268+
const tag = marker?.text?.substring("frsh-".length) ?? "";
269+
const [id] = tag.split(":");
270+
performance.mark(tag);
258271
render(
259272
vnode,
260273
createRootFragment(
@@ -264,6 +277,8 @@ function _walkInner(
264277
// deno-lint-ignore no-explicit-any
265278
) as any as HTMLElement,
266279
);
280+
performance.measure(`hydrate: ${id}`, tag);
281+
};
267282

268283
"scheduler" in window
269284
// `scheduler.postTask` is async but that can easily

0 commit comments

Comments
 (0)