Skip to content

Commit

Permalink
feat: added bundler module.
Browse files Browse the repository at this point in the history
  • Loading branch information
eser committed Jan 16, 2024
1 parent 773eecc commit e2cd8c5
Show file tree
Hide file tree
Showing 9 changed files with 385 additions and 2 deletions.
10 changes: 8 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ repos:
args: ["--maxkb=1024"]
exclude: |
(?x)^(
lime/build/esbuild_v0.19.2.wasm
bundler/esbuild_v0.19.11.wasm
)$
- id: check-case-conflict
- id: check-executables-have-shebangs
Expand Down Expand Up @@ -63,15 +63,21 @@ repos:
.github/PULL_REQUEST_TEMPLATE.md|
.git/COMMIT_EDITMSG|
appserver/README.md|
bundler/README.md|
bundler/esbuild_v0.19.11.wasm|
collector/README.md|
di/README.md|
directives/README.md|
docs/.*|
dotenv/README.md|
events/README.md|
file-loader/README.md|
fp/README.md|
functions/README.md|
jsx-runtime/README.md|
lime/.*|
jsx-runtime.test/README.md|
lime/README.md|
lime.test/README.md|
parsing/README.md|
standards/README.md|
CODEOWNERS|
Expand Down
79 changes: 79 additions & 0 deletions bundler/aot-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.

import * as runtime from "../standards/runtime.ts";
import { colors, path } from "./deps.ts";
import { type BuildSnapshot, type BuildSnapshotJson } from "./mod.ts";
import { setBuildId } from "./build-id.ts";

export class AotSnapshot implements BuildSnapshot {
#files: Map<string, string>;
#dependencies: Map<string, Array<string>>;

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

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

async read(pathStr: string): Promise<ReadableStream<Uint8Array> | null> {
const filePath = this.#files.get(pathStr);
if (filePath !== undefined) {
try {
const file = await runtime.current.open(filePath, { read: true });
return file.readable;
} catch (_err) {
return null;
}
}

// Handler will turn this into a 404
return null;
}

dependencies(pathStr: string): Array<string> {
return this.#dependencies.get(pathStr) ?? [];
}
}

export async function loadAotSnapshot(
snapshotDirPath: string,
): Promise<AotSnapshot | null> {
try {
if ((await runtime.current.stat(snapshotDirPath)).isDirectory) {
console.log(
`Using snapshot found at ${colors.cyan(snapshotDirPath)}`,
);

const snapshotPath = path.join(snapshotDirPath, "snapshot.json");
const json = JSON.parse(
await runtime.current.readTextFile(snapshotPath),
) as BuildSnapshotJson;
setBuildId(json.build_id);

const dependencies = new Map<string, Array<string>>(
Object.entries(json.files),
);

const files = new Map<string, string>();
Object.keys(json.files).forEach((name) => {
const filePath = path.join(snapshotDirPath, name);
files.set(name, filePath);
});

return new AotSnapshot(files, dependencies);
}
return null;
} catch (err) {
if (!(err instanceof runtime.current.errors.NotFound)) {
throw err;
}

return null;
}
}
21 changes: 21 additions & 0 deletions bundler/build-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.

import * as runtime from "../standards/runtime.ts";
import { hex } from "./deps.ts";

const env = runtime.current.getEnv();

const deploymentId = env["DENO_DEPLOYMENT_ID"] ||
// For CI
env["GITHUB_SHA"] ||
crypto.randomUUID();
const buildIdHash = await crypto.subtle.digest(
"SHA-1",
new TextEncoder().encode(deploymentId),
);

export let BUILD_ID = hex.encodeHex(buildIdHash);

export function setBuildId(buildId: string) {
BUILD_ID = buildId;
}
8 changes: 8 additions & 0 deletions bundler/deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.

export * as colors from "https://deno.land/std@0.212.0/fmt/colors.ts";
export * as hex from "https://deno.land/std@0.212.0/encoding/hex.ts";
export * as path from "https://deno.land/std@0.212.0/path/mod.ts";
export * as regexpEscape from "https://deno.land/std@0.212.0/regexp/escape.ts";

export * as esbuild from "https://deno.land/x/esbuild_deno_loader@0.8.3/mod.ts";
215 changes: 215 additions & 0 deletions bundler/esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.

import {
type BuildOptions,
type OnLoadOptions,
type Plugin,
} from "https://deno.land/x/esbuild@v0.19.11/mod.js";
import * as runtime from "../standards/runtime.ts";
import { esbuild, path, regexpEscape } from "./deps.ts";
import { Builder, BuildSnapshot } from "./mod.ts";
// import { BUNDLE_PUBLIC_PATH } from "../server/constants.ts";

export interface EsbuildBuilderOptions {
/** The build ID. */
buildID: string;
/** The entrypoints, mapped from name to URL. */
entrypoints: Record<string, string>;
/** Whether or not this is a dev build. */
dev: boolean;
/** The path to the deno.json / deno.jsonc config file. */
configPath: string;
/** The JSX configuration. */
jsx?: string;
jsxImportSource?: string;
target: string | Array<string>;
absoluteWorkingDir: string;
basePath?: string;
}

export class EsbuildBuilder implements Builder {
#options: EsbuildBuilderOptions;

constructor(options: EsbuildBuilderOptions) {
this.#options = options;
}

async build(): Promise<EsbuildSnapshot> {
const opts = this.#options;

const env = runtime.current.getEnv();

const isOnDenoDeploy = env["DENO_DEPLOYMENT_ID"] !== undefined;
const portableBuilder = env["LIME_ESBUILD_LOADER"] === "portable";

// Lazily initialize esbuild
// @deno-types="https://deno.land/x/esbuild@v0.19.11/mod.d.ts"
const esbuildInstance = isOnDenoDeploy || portableBuilder
? await import("https://deno.land/x/esbuild@v0.19.11/wasm.js")
: await import("https://deno.land/x/esbuild@v0.19.11/mod.js");
const esbuildWasmURL =
new URL("./esbuild_v0.19.11.wasm", import.meta.url).href;

if (isOnDenoDeploy) {
await esbuildInstance.initialize({
wasmURL: esbuildWasmURL,
worker: false,
});
} else {
await esbuildInstance.initialize({});
}

try {
const absWorkingDir = opts.absoluteWorkingDir;

// In dev-mode we skip identifier minification to be able to show proper
// component names in React DevTools instead of single characters.
const minifyOptions: Partial<BuildOptions> = opts.dev
? {
minifyIdentifiers: false,
minifySyntax: true,
minifyWhitespace: true,
}
: { minify: true };

const bundle = await esbuildInstance.build({
entryPoints: opts.entrypoints,

platform: "browser",
target: this.#options.target,

format: "esm",
bundle: true,
splitting: true,
treeShaking: true,
sourcemap: opts.dev ? "linked" : false,
...minifyOptions,

jsx: opts.jsx === "react"
? "transform"
: opts.jsx === "react-native" || opts.jsx === "preserve"
? "preserve"
: !opts.jsxImportSource
? "transform"
: "automatic",
jsxImportSource: opts.jsxImportSource ?? "react",

absWorkingDir,
outdir: ".",
// publicPath: BUNDLE_PUBLIC_PATH,
write: false,
metafile: true,

plugins: [
devClientUrlPlugin(opts.basePath),
buildIdPlugin(opts.buildID),
...esbuild.denoPlugins({ configPath: opts.configPath }),
],
});

const files = new Map<string, Uint8Array>();
const dependencies = new Map<string, Array<string>>();

for (const file of bundle.outputFiles) {
const relativePath = path.relative(absWorkingDir, file.path);
files.set(relativePath, file.contents);
}

files.set(
"metafile.json",
new TextEncoder().encode(JSON.stringify(bundle.metafile)),
);

const metaOutputs = new Map(Object.entries(bundle.metafile.outputs));

for (const [pathStr, entry] of metaOutputs.entries()) {
const imports = entry.imports
.filter((importItem) => importItem.kind === "import-statement")
.map((importItem) => importItem.path);
dependencies.set(pathStr, imports);
}

return new EsbuildSnapshot(files, dependencies);
} finally {
esbuildInstance.stop();
}
}
}

function devClientUrlPlugin(basePath?: string): Plugin {
return {
name: "dev-client-url",
setup(build) {
build.onLoad(
{ filter: /client\.ts$/, namespace: "file" },
async (args) => {
// Load the original script
const contents = await runtime.current.readTextFile(args.path);

// Replace the URL
const modifiedContents = contents.replace(
"/_lime/alive",
`${basePath}/_lime/alive`,
);

return {
contents: modifiedContents,
loader: "ts",
};
},
);
},
};
}

function buildIdPlugin(buildId: string): Plugin {
const file = import.meta.resolve("../runtime/build-id.ts");
const url = new URL(file);
let options: OnLoadOptions;

if (url.protocol === "file:") {
const pathNormalized = path.fromFileUrl(url);
const filter = new RegExp(`^${regexpEscape.escape(pathNormalized)}$`);
options = { filter, namespace: "file" };
} else {
const namespace = url.protocol.slice(0, -1);
const pathNormalized = url.href.slice(namespace.length + 1);
const filter = new RegExp(`^${regexpEscape.escape(pathNormalized)}$`);
options = { filter, namespace };
}

return {
name: "lime-build-id",
setup(build) {
build.onLoad(
options,
() => ({ contents: `export const BUILD_ID = "${buildId}";` }),
);
},
};
}

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

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

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

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

dependencies(pathStr: string): Array<string> {
return this.#dependencies.get(pathStr) ?? [];
}
}
Binary file added bundler/esbuild_v0.19.11.wasm
Binary file not shown.
36 changes: 36 additions & 0 deletions bundler/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.

export {
EsbuildBuilder,
type EsbuildBuilderOptions,
EsbuildSnapshot,
} from "./esbuild.ts";
export { AotSnapshot } from "./aot-snapshot.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: Array<string>;

/** 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
| Promise<ReadableStream<Uint8Array> | Uint8Array | null>;

/** For a given entrypoint, return it's list of dependencies.
*
* Returns an empty array if the entrypoint does not exist. */
dependencies(pathStr: string): Array<string>;
}

export interface BuildSnapshotJson {
build_id: string;
files: Record<string, Array<string>>;
}
Loading

0 comments on commit e2cd8c5

Please sign in to comment.