diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index c7cb78b426..71a10e9393 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -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(); diff --git a/src/bake/production.zig b/src/bake/production.zig index 4e796c155b..58c53279f1 100644 --- a/src/bake/production.zig +++ b/src/bake/production.zig @@ -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); }, }; diff --git a/test/bake/dev-server-harness.ts b/test/bake/dev-server-harness.ts index 947b37d132..49cf8f572e 100644 --- a/test/bake/dev-server-harness.ts +++ b/test/bake/dev-server-harness.ts @@ -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/ + * This directory must contain `bun.app.ts` to allow hacking on fixtures manually via `bun run .` + */ + fixture: string; +}) & { test: (dev: Dev) => Promise; } @@ -327,31 +334,47 @@ export function devTest(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(description: string, options: T dedent` import appConfig from "./bun.app.ts"; export default { + ...appConfig, port: 0, - ...appConfig }; `, ); @@ -373,6 +396,7 @@ export function devTest(description: string, options: T { FORCE_COLOR: "1", BUN_DEV_SERVER_TEST_RUNNER: "1", + BUN_DUMP_STATE_ON_CRASH: "1", }, ]), stdio: ["pipe", "pipe", "pipe"], diff --git a/test/bake/dev/ecosystem.test.ts b/test/bake/dev/ecosystem.test.ts index 068795a875..5a3a6f6c78 100644 --- a/test/bake/dev/ecosystem.test.ts +++ b/test/bake/dev/ecosystem.test.ts @@ -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 \ No newline at end of file +// - 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(`

This is my svelte server component (non-interactive)

Bun v${Bun.version}

`); + expect(html).toContain(`>This is a client component (interactive island)

`); + // TODO: puppeteer test for client-side interactivity, hmr. + // care must be taken to implement this in a way that is not flaky. + }, +}); diff --git a/test/bake/fixtures/svelte-component-islands/bun.app.ts b/test/bake/fixtures/svelte-component-islands/bun.app.ts new file mode 100644 index 0000000000..9813768a22 --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/bun.app.ts @@ -0,0 +1,8 @@ +import svelte from "./framework"; + +export default { + port: 3000, + app: { + framework: svelte(), + }, +}; diff --git a/test/bake/fixtures/svelte-component-islands/framework/client.ts b/test/bake/fixtures/svelte-component-islands/framework/client.ts new file mode 100644 index 0000000000..aca37275e0 --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/framework/client.ts @@ -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, + }); + } +}); diff --git a/test/bake/fixtures/svelte-component-islands/framework/index.ts b/test/bake/fixtures/svelte-component-islands/framework/index.ts new file mode 100644 index 0000000000..7cecf75358 --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/framework/index.ts @@ -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(); + 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*\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" }; + }); + }, + }, + ], + }; +} diff --git a/test/bake/fixtures/svelte-component-islands/framework/server.ts b/test/bake/fixtures/svelte-component-islands/framework/server.ts new file mode 100644 index 0000000000..29253245f8 --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/framework/server.ts @@ -0,0 +1,71 @@ +/// +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) => ``).join("") + + meta.modulepreload.map((style) => ``).join(""); + // Script tags + const scripts = nextIslandId > 0 + ? `` + + meta.modules.map((module) => ``).join("") + : ""; // If no islands, no JavaScript + + return new Response( + "" + head + extraHead + "" + + body + "" + scripts + "", + { 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; +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 += ``; + const file = (islands[clientModuleId] ??= []); + file.push([islandId, clientExportId, props]); + component(...args); + payload.out += ``; + isInsideIsland = false; + }; +} diff --git a/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte b/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte new file mode 100644 index 0000000000..7c4a618edb --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte @@ -0,0 +1,24 @@ + + +
+

This is a client component (interactive island)

+ +
+ diff --git a/test/bake/fixtures/svelte-component-islands/pages/index.svelte b/test/bake/fixtures/svelte-component-islands/pages/index.svelte new file mode 100644 index 0000000000..f5b8c0728c --- /dev/null +++ b/test/bake/fixtures/svelte-component-islands/pages/index.svelte @@ -0,0 +1,18 @@ + +
+

hello

+

This is my svelte server component (non-interactive)

+

Bun v{Bun.version}

+ +
+ \ No newline at end of file diff --git a/test/bun.lockb b/test/bun.lockb index 6a1061e911..699279fcb2 100755 Binary files a/test/bun.lockb and b/test/bun.lockb differ diff --git a/test/package.json b/test/package.json index 03a8717ce3..f643ef682d 100644 --- a/test/package.json +++ b/test/package.json @@ -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",