This commit is contained in:
dave caruso
2024-12-02 17:06:22 -08:00
committed by snwy
parent 8875454c47
commit 56b729d87b
12 changed files with 273 additions and 34 deletions

View File

@@ -4353,7 +4353,7 @@ fn dumpStateDueToCrash(dev: *DevServer) !void {
const filepath = std.fmt.bufPrintZ(&filepath_buf, "incremental-graph-crash-dump.{d}.html", .{std.time.timestamp()}) catch "incremental-graph-crash-dump.html";
const file = std.fs.cwd().createFileZ(filepath, .{}) catch |err| {
bun.handleErrorReturnTrace(err, @errorReturnTrace());
Output.warn("Could not open directory for dumping sources: {}", .{err});
Output.warn("Could not open file for dumping incremental graph: {}", .{err});
return;
};
defer file.close();

View File

@@ -155,7 +155,7 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa
break :config try bake.UserOptions.fromJS(app, vm.global);
},
.rejected => |err| {
return global.throwValue2(err.toError() orelse err);
return global.throwValue(err.toError() orelse err);
},
};

View File

@@ -27,7 +27,9 @@ export const minimalFramework: Bake.Framework = {
},
};
export interface DevServerTest {
export type DevServerTest = ({
/** Starting files */
files: FileObject;
/**
* Framework to use. Consider `minimalFramework` if possible.
* Provide this object or `files['bun.app.ts']` for a dynamic one.
@@ -38,8 +40,13 @@ export interface DevServerTest {
* combined with the `framework` option.
*/
pluginFile?: string;
/** Starting files */
files: FileObject;
} | {
/**
* Copy all files from test/bake/fixtures/<name>
* This directory must contain `bun.app.ts` to allow hacking on fixtures manually via `bun run .`
*/
fixture: string;
}) & {
test: (dev: Dev) => Promise<void>;
}
@@ -327,31 +334,47 @@ export function devTest<T extends DevServerTest>(description: string, options: T
jest.test(`DevServer > ${basename}.${count}: ${description}`, async () => {
const root = path.join(tempDir, basename + count);
writeAll(root, options.files);
if (options.files["bun.app.ts"] == undefined) {
if (!options.framework) {
throw new Error("Must specify a options.framework or provide a bun.app.ts file");
if ('files' in options) {
writeAll(root, options.files);
if (options.files["bun.app.ts"] == undefined) {
if (!options.framework) {
throw new Error("Must specify a options.framework or provide a bun.app.ts file");
}
if (options.pluginFile) {
fs.writeFileSync(path.join(root, "pluginFile.ts"), dedent(options.pluginFile));
}
fs.writeFileSync(
path.join(root, "bun.app.ts"),
dedent`
${options.pluginFile ?
`import plugins from './pluginFile.ts';` : "let plugins = undefined;"
}
export default {
app: {
framework: ${JSON.stringify(options.framework)},
plugins,
},
};
`,
);
} else {
if (options.pluginFile) {
throw new Error("Cannot provide both bun.app.ts and pluginFile");
}
}
if (options.pluginFile) {
fs.writeFileSync(path.join(root, "pluginFile.ts"), dedent(options.pluginFile));
}
fs.writeFileSync(
path.join(root, "bun.app.ts"),
dedent`
${options.pluginFile ?
`import plugins from './pluginFile.ts';` : "let plugins = undefined;"
}
export default {
app: {
framework: ${JSON.stringify(options.framework)},
plugins,
},
};
`,
);
} else {
if (options.pluginFile) {
throw new Error("Cannot provide both bun.app.ts and pluginFile");
if (!options.fixture) {
throw new Error("Must provide either `fixture` or `files`");
}
const fixture = path.join(devTestRoot, "../fixtures", options.fixture);
fs.cpSync(fixture, root, { recursive: true });
if(!fs.existsSync(path.join(root, "bun.app.ts"))) {
throw new Error(`Fixture ${fixture} must contain a bun.app.ts file.`);
}
if (!fs.existsSync(path.join(root, "node_modules"))) {
// link the node_modules directory from test/node_modules to the temp directory
fs.symlinkSync(path.join(devTestRoot, "../../node_modules"), path.join(root, "node_modules"), "junction");
}
}
fs.writeFileSync(
@@ -359,8 +382,8 @@ export function devTest<T extends DevServerTest>(description: string, options: T
dedent`
import appConfig from "./bun.app.ts";
export default {
...appConfig,
port: 0,
...appConfig
};
`,
);
@@ -373,6 +396,7 @@ export function devTest<T extends DevServerTest>(description: string, options: T
{
FORCE_COLOR: "1",
BUN_DEV_SERVER_TEST_RUNNER: "1",
BUN_DUMP_STATE_ON_CRASH: "1",
},
]),
stdio: ["pipe", "pipe", "pipe"],

View File

@@ -2,10 +2,21 @@
// should be preferred to write specific tests for the bugs that these libraries
// discovered, but it easy and still a reasonable idea to just test the library
// entirely.
import { expect } from "bun:test";
import { devTest } from "../dev-server-harness";
// TODO: svelte server component example project
// Bugs discovered thanks to Svelte:
// - Valid circular import use.
// - Re-export `.e_import_identifier`, including live bindings.
// TODO: - something related to the wrong push function being called
// - Circular import situations
// - export { live_binding }
// - export { x as y }
devTest('svelte component islands example', {
fixture: 'svelte-component-islands',
async test(dev) {
const html = await dev.fetch('/').text()
expect(html).toContain('self.$islands={\"pages/_Counter.svelte\":[[0,\"default\",{initial:5}]]}');
expect(html).toContain(`<p>This is my svelte server component (non-interactive)</p> <p>Bun v${Bun.version}</p>`);
expect(html).toContain(`>This is a client component (interactive island)</p>`);
// TODO: puppeteer test for client-side interactivity, hmr.
// care must be taken to implement this in a way that is not flaky.
},
});

View File

@@ -0,0 +1,8 @@
import svelte from "./framework";
export default {
port: 3000,
app: {
framework: svelte(),
},
};

View File

@@ -0,0 +1,14 @@
import type { IslandMap } from "./server";
import { hydrate } from 'svelte';
declare var $islands: IslandMap;
Object.entries($islands).forEach(async([moduleId, islands]) => {
const mod = await import(moduleId);
for(const [islandId, exportId, props] of islands) {
const elem = document.getElementById(`I:${islandId}`)!;
hydrate(mod[exportId], {
target: elem,
props,
});
}
});

View File

@@ -0,0 +1,68 @@
import type { Bake } from "bun";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import * as svelte from "svelte/compiler";
export default function (): Bake.Framework {
return {
serverComponents: {
separateSSRGraph: false,
serverRuntimeImportSource: "./framework/server.ts",
},
fileSystemRouterTypes: [
{
root: "pages",
serverEntryPoint: "./framework/server.ts",
clientEntryPoint: "./framework/client.ts",
style: "nextjs-pages", // later, this will be fully programmable
extensions: [".svelte"],
},
],
plugins: [
{
// This is missing a lot of code that a plugin like `esbuild-svelte`
// handles, but this is only an examplea of how such a plugin could
// have server-components at a minimal level.
name: "svelte-server-components",
setup(b) {
const cssMap = new Map<string, string>();
b.onLoad({ filter: /.svelte$/ }, async (args) => {
const contents = await fs.readFile(args.path, "utf-8");
const result = svelte.compile(contents, {
filename: args.path,
css: "external",
cssOutputFilename: path.basename(args.path, ".svelte") + ".css",
hmr: true,
dev: true,
generate: args.side,
});
// If CSS is specified, add a CSS import
let jsCode = result.js.code;
if (result.css) {
cssMap.set(args.path, result.css.code);
jsCode = `import ${JSON.stringify("svelte-css:" + args.path)};` + jsCode;
}
// Extract a "use client" directive from the file.
const header = contents.match(/^\s*<script.*?>\s*("[^"\n]*"|'[^'\n]*')/)?.[1];
if (header) {
jsCode = header + ';' + jsCode;
}
return {
contents: jsCode,
loader: "js",
watchFiles: [args.path],
};
});
// Resolve CSS files
b.onResolve({ filter: /^svelte-css:/ }, async (args) => {
return { path: args.path.replace(/^svelte-css:/, ""), namespace: "svelte-css" };
});
b.onLoad({ filter: /./, namespace: "svelte-css" }, async (args) => {
return { contents: cssMap.get(args.path) ?? "", loader: "css" };
});
},
},
],
};
}

View File

@@ -0,0 +1,71 @@
/// <reference path="../../../../../src/bake/bake.d.ts" />
import type { Bake } from "bun";
import * as svelte from "svelte/server";
import { uneval } from "devalue";
export function render(req: Request, meta: Bake.RouteMetadata) {
isInsideIsland = false;
islands = {};
const { body, head } = svelte.render(meta.pageModule.default, {
props: {
params: meta.params,
},
});
// Add stylesheets and preloaded modules to the head
const extraHead = meta.styles.map((style) => `<link rel="stylesheet" href="${style}">`).join("")
+ meta.modulepreload.map((style) => `<link rel="modulepreload" href="${style}">`).join("");
// Script tags
const scripts = nextIslandId > 0
? `<script>self.$islands=${uneval(islands)}</script>` +
meta.modules.map((module) => `<script type="module" src="${module}"></script>`).join("")
: ""; // If no islands, no JavaScript
return new Response(
"<!DOCTYPE html><html><head>" + head + extraHead + "</head><body>"
+ body + "</body>" + scripts + "</html>",
{ headers: { "content-type": "text/html" } },
);
}
// To allow static site generation, frameworks can specify a prerender function
export function prerender(meta: Bake.RouteMetadata) {
return {
files: {
'/index.html': render(null!, meta),
},
};
}
let isInsideIsland = false;
let nextIslandId = 0;
let islands: IslandMap;
export type IslandMap = Record<string, Island[]>;
export type Island = [islandId: number, exportId: string, props: any];
/**
* @param component The original export value, as is.
* @param clientModuleId A string that the browser will pass to `import()`.
* @param clientExportId The export ID from the imported module.
* @returns A wrapped value for the export.
*/
export function registerClientReference(
component: Function,
clientModuleId: string,
clientExportId: string,
) {
return function Island(...args: any[]) {
if (isInsideIsland) {
return component(...args);
}
isInsideIsland = true;
const [payload, props] = args;
const islandId = nextIslandId++;
payload.out += `<bake-island id="I:${islandId}">`;
const file = (islands[clientModuleId] ??= []);
file.push([islandId, clientExportId, props]);
component(...args);
payload.out += `</bake-island>`;
isInsideIsland = false;
};
}

View File

@@ -0,0 +1,24 @@
<script>
"use client";
let { initial } = $props();
let count = $state(initial);
function increment() {
count += 1;
}
</script>
<div>
<p>This is a client component (interactive island)</p>
<button onclick={increment}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
</div>
<style>
div {
border: 1px solid red;
padding: 1rem;
p {
color: red;
}
}
</style>

View File

@@ -0,0 +1,18 @@
<script>
import Counter from "./_Counter.svelte";
</script>
<main>
<h1>hello</h1>
<p>This is my svelte server component (non-interactive)</p>
<p>Bun v{Bun.version}</p>
<Counter initial={5} />
</main>
<style>
main {
border: 1px solid blue;
padding: 1rem;
h1 {
color: blue;
}
}
</style>

Binary file not shown.

View File

@@ -21,6 +21,7 @@
"axios": "1.6.8",
"body-parser": "1.20.2",
"comlink": "4.4.1",
"devalue": "5.1.1",
"es-module-lexer": "1.3.0",
"esbuild": "0.18.6",
"express": "4.18.2",
@@ -59,7 +60,7 @@
"string-width": "7.0.0",
"stripe": "15.4.0",
"supertest": "6.3.3",
"svelte": "3.55.1",
"svelte": "5.4.0",
"typescript": "5.0.2",
"undici": "5.20.0",
"verdaccio": "6.0.0",