@pikku/deploy-standalone 0.12.11 → 0.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/adapter.d.ts +37 -45
  3. package/dist/adapter.js +187 -46
  4. package/dist/index.d.ts +1 -1
  5. package/dist/runtime/index.d.ts +9 -0
  6. package/dist/runtime/index.js +8 -0
  7. package/dist/runtime/parent-watch.d.ts +45 -0
  8. package/dist/runtime/parent-watch.js +87 -0
  9. package/dist/tauri/generate.d.ts +45 -0
  10. package/dist/tauri/generate.js +230 -0
  11. package/dist/tauri/icon.d.ts +1 -0
  12. package/dist/tauri/icon.js +54 -0
  13. package/dist/tauri/main-rs.d.ts +31 -0
  14. package/dist/tauri/main-rs.js +213 -0
  15. package/dist/tauri/next-steps.d.ts +15 -0
  16. package/dist/tauri/next-steps.js +16 -0
  17. package/dist/tauri/target-triple.d.ts +29 -0
  18. package/dist/tauri/target-triple.js +42 -0
  19. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  20. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  21. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  22. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  23. package/knowledge/decisions/index.md +19 -0
  24. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  25. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  26. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  27. package/knowledge/index.md +22 -0
  28. package/package.json +10 -5
  29. package/src/adapter.test.ts +186 -0
  30. package/src/adapter.ts +216 -62
  31. package/src/desktop-deploy.test.ts +167 -0
  32. package/src/index.ts +1 -3
  33. package/src/runtime/index.ts +13 -0
  34. package/src/runtime/parent-watch.process.test.ts +112 -0
  35. package/src/runtime/parent-watch.test.ts +148 -0
  36. package/src/runtime/parent-watch.ts +115 -0
  37. package/src/sidecar-entry.test.ts +89 -0
  38. package/src/tauri/generate.test.ts +401 -0
  39. package/src/tauri/generate.ts +327 -0
  40. package/src/tauri/icon.test.ts +63 -0
  41. package/src/tauri/icon.ts +62 -0
  42. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  43. package/src/tauri/main-rs.ts +241 -0
  44. package/src/tauri/next-steps.test.ts +38 -0
  45. package/src/tauri/next-steps.ts +30 -0
  46. package/src/tauri/target-triple.test.ts +84 -0
  47. package/src/tauri/target-triple.ts +65 -0
  48. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Rust target triples, and the file name Tauri's `externalBin` resolves a
3
+ * sidecar by.
4
+ *
5
+ * Tauri appends the *compile* target's triple to every `externalBin` path and
6
+ * looks the result up on disk, so a binary dropped in as plain `binaries/app`
7
+ * is silently invisible to the bundler. This is the one detail that makes an
8
+ * otherwise correct shell fail at build time with "binary not found".
9
+ */
10
+ const TRIPLES = {
11
+ 'darwin:arm64': 'aarch64-apple-darwin',
12
+ 'darwin:x64': 'x86_64-apple-darwin',
13
+ 'linux:arm64': 'aarch64-unknown-linux-gnu',
14
+ 'linux:x64': 'x86_64-unknown-linux-gnu',
15
+ 'win32:arm64': 'aarch64-pc-windows-msvc',
16
+ 'win32:x64': 'x86_64-pc-windows-msvc',
17
+ };
18
+ /** Pull the `host:` line out of `rustc -vV` output. */
19
+ export const parseRustcHost = (output) => /^host:\s*(\S+)$/m.exec(output)?.[1];
20
+ /**
21
+ * The triple the shell will be built for.
22
+ *
23
+ * `rustc -vV` wins when it is available: it is the toolchain that will link the
24
+ * shell, and it knows things the Node platform pair cannot express — a musl
25
+ * host, or a Node process running under Rosetta on an arm64 Mac.
26
+ */
27
+ export const hostTargetTriple = (options = {}) => {
28
+ const fromRustc = options.rustcVersionVerbose
29
+ ? parseRustcHost(options.rustcVersionVerbose)
30
+ : undefined;
31
+ if (fromRustc)
32
+ return fromRustc;
33
+ const platform = options.platform ?? process.platform;
34
+ const arch = options.arch ?? process.arch;
35
+ const triple = TRIPLES[`${platform}:${arch}`];
36
+ if (!triple) {
37
+ throw new Error(`No known Rust target triple for ${platform}/${arch}. Install a Rust toolchain so \`rustc -vV\` can report its host, or pass the triple explicitly.`);
38
+ }
39
+ return triple;
40
+ };
41
+ /** `binaries/<name>-<triple>[.exe]`, exactly as `externalBin` looks it up. */
42
+ export const sidecarFileName = (baseName, targetTriple) => `${baseName}-${targetTriple}${targetTriple.includes('windows') ? '.exe' : ''}`;
@@ -0,0 +1,36 @@
1
+ ---
2
+ type: decision
3
+ title: A pikku server serves a static frontend, not a rendered one
4
+ description: The frontend is TanStack Start built to static output and served through a static mount; pikku never runs a framework renderer in-process
5
+ tags: [frontend, tanstack, static-mounts, standalone]
6
+ ---
7
+
8
+ # A pikku server serves a static frontend, not a rendered one
9
+
10
+ The frontend story is one framework — **TanStack Start** — and one output shape:
11
+ static HTML, JS and CSS on disk, served through the same `StaticMount`
12
+ machinery that already serves the console. Next.js is out of scope, and so is
13
+ running any framework's server renderer inside the pikku process.
14
+
15
+ Both halves of that are deliberate, and for the same reason. Hosting a renderer
16
+ means owning that framework's server contract: its request/response adapter, its
17
+ streaming model, its middleware ordering, its version skew. It couples a pikku
18
+ release to a frontend framework release, and it multiplies by every runtime we
19
+ support — the node server, the bun server, and every serverless adapter would
20
+ each need their own integration. A directory of files needs none of that, and
21
+ what it costs the app is per-request server rendering, which a local-first
22
+ desktop app was never going to use.
23
+
24
+ The capability is not standalone-specific. `pikku serve` and `pikku dev` mount a
25
+ frontend the same way, because a server that can serve its own UI is useful long
26
+ before anyone wraps it in Tauri — it is the difference between one origin and
27
+ two, which is also the difference between first-party cookies and a CORS
28
+ configuration. Standalone is the case that makes it *feel* like an app; it is
29
+ not the case that justifies the feature.
30
+
31
+ Dev does not use a mount at all. Vite serves the frontend and proxies `/api` to
32
+ pikku, exactly as `packages/console/vite.config.ts` already does — HMR is the
33
+ whole point of dev, and a static mount cannot offer it.
34
+
35
+ **What this rules out:** a Next.js integration, an in-process SSR or streaming
36
+ handler, and a frontend capability that only exists inside `pikku deploy`.
@@ -0,0 +1,38 @@
1
+ ---
2
+ type: decision
3
+ title: A remote desktop shell bundles nothing
4
+ description: --desktop-url produces a window onto an already-deployed server — no sidecar, no binary, no bun requirement — with the window declared in tauri.conf.json rather than opened from Rust
5
+ tags: [tauri, desktop, standalone, remote]
6
+ ---
7
+
8
+ # A remote desktop shell bundles nothing
9
+
10
+ `pikku deploy apply --desktop-url https://app.example.com` generates the same
11
+ `src-tauri/` crate as the sidecar shell, minus everything that exists to run a
12
+ server: no `externalBin`, no `tauri-plugin-shell`, no binary copied into
13
+ `binaries/`, and no `--runtime bun` requirement, because there is nothing to
14
+ compile. The app is a window onto a server someone else's deploy already put on
15
+ the internet.
16
+
17
+ The window is **declared in `tauri.conf.json`**, not built from Rust. That is
18
+ the whole difference in the generated program. A sidecar binds its port at
19
+ launch, so its origin is unknown until it says so and only Rust can open the
20
+ window; a remote url is known before a line of Rust is written, so `main.rs`
21
+ keeps nothing but the single-instance plugin.
22
+
23
+ The origin rule from
24
+ [the sidecar shell](the-desktop-shell-runs-the-server-as-a-sidecar.md) is
25
+ unchanged and is why this mode is worth having at all: the webview loads the
26
+ real `https://` origin, so cookies are first-party, there is no CORS, and OAuth
27
+ redirects land where the server expects. A shell that instead bundled the
28
+ frontend and served it from `tauri://localhost` would break every one of those
29
+ — which is exactly what "remote origin only" rules out.
30
+
31
+ The url is validated as `http:` or `https:` at generate time. Anything else
32
+ builds a crate that fails at runtime, in a window with no address bar to
33
+ diagnose it from.
34
+
35
+ **What this rules out:** bundling the frontend into the shell, a shell that
36
+ falls back to a local server when the remote is unreachable, and mixing the two
37
+ modes — a binary and a url together is refused rather than silently shipping a
38
+ sidecar nothing starts.
@@ -0,0 +1,33 @@
1
+ ---
2
+ type: decision
3
+ title: Deploy consumes a built frontend, it does not build one
4
+ description: pikku reads an already-built client directory named by the frontend config key; running the frontend's build command is the project's job
5
+ tags: [frontend, deploy, standalone, cli]
6
+ ---
7
+
8
+ # Deploy consumes a built frontend, it does not build one
9
+
10
+ `frontend` in `pikku.config.json` names a **directory of built output** —
11
+ `{ dir: './web/dist', urlPrefix: '/', spaFallback: true }` — not a project to
12
+ build. `pikku deploy` reads that directory. It does not run `vite build`, does
13
+ not shell out to a package manager, and fails with a plain error if the
14
+ directory is absent rather than trying to produce it.
15
+
16
+ Building the frontend would mean pikku deciding which package manager runs,
17
+ which script name means "build", which workspace the frontend lives in, what
18
+ environment variables it needs, and what to do when that build fails inside a
19
+ deploy. Every one of those is a project-level answer that pikku would be
20
+ guessing at, and guessing wrong is worse than not trying: a deploy that
21
+ silently rebuilds can ship output that differs from what the project's own CI
22
+ verified. `yarn build && pikku deploy` states the order explicitly and keeps the
23
+ two steps independently debuggable.
24
+
25
+ The ordering constraint that this creates is real and worth naming. The bun path
26
+ embeds assets by generating a manifest of static imports, which means the build
27
+ sequence is fixed: **frontend build → manifest generation → server bundle →
28
+ `bun build --compile`**. A frontend that has not been built yet cannot be
29
+ enumerated, so there is no arrangement in which pikku could usefully build it
30
+ later.
31
+
32
+ **What this rules out:** a `frontend.build` command in the config, and any
33
+ deploy step that invokes a package manager on the user's behalf.
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: decision
3
+ title: Desktop builds are unsigned and never update themselves
4
+ description: Code signing, notarization and auto-update are deliberately absent from the first version of the Tauri shell — a known limitation, not an oversight
5
+ tags: [tauri, desktop, limitations, distribution]
6
+ ---
7
+
8
+ # Desktop builds are unsigned and never update themselves
9
+
10
+ The generated shell has no signing pipeline and no updater. Both were left out
11
+ on purpose, and both need building before anything is distributed to people who
12
+ did not compile it themselves.
13
+
14
+ What this means in practice today:
15
+
16
+ - **macOS Gatekeeper will refuse the app on first launch.** An unsigned,
17
+ un-notarized `.app` downloaded from anywhere gets quarantined; the user has to
18
+ right-click → Open, or clear the quarantine attribute by hand. This is
19
+ accepted for now.
20
+ - **Windows SmartScreen shows an unknown-publisher warning** for the same reason.
21
+ - **There is no update path.** A shipped build stays the version it was. Tauri's
22
+ updater plugin is not wired in, no update endpoint exists, and — relevant
23
+ later — the updater requires signing keys, so the two gaps have to be closed
24
+ together rather than in either order.
25
+
26
+ Signing is not something the generator can quietly grow, because it needs
27
+ secrets the build machine has to hold: an Apple Developer ID certificate plus an
28
+ app-specific password or API key for notarization, and an Authenticode
29
+ certificate on Windows. That is CI configuration and key custody, not code
30
+ generation, which is why it is a separate piece of work rather than a flag.
31
+
32
+ **What this rules out for now:** distributing a build to end users without
33
+ telling them how to get past Gatekeeper, and any claim that a shipped desktop
34
+ app can be patched after release.
@@ -0,0 +1,19 @@
1
+ ---
2
+ type: overview
3
+ title: Decisions
4
+ description: What "a standalone unit with a UI" does and does not mean, and what a double-clickable build of it is
5
+ ---
6
+
7
+ # Decisions
8
+
9
+ <!-- pikku:knowledge-index -->
10
+
11
+ - [A remote desktop shell bundles nothing](a-remote-desktop-shell-bundles-nothing.md) — --desktop-url produces a window onto an already-deployed server — no sidecar, no binary, no bun requirement — with the window declared in tauri.conf.json rather than opened from Rust
12
+ - [A pikku server serves a static frontend, not a rendered one](a-pikku-server-serves-a-static-frontend.md) — The frontend is TanStack Start built to static output and served through a static mount; pikku never runs a framework renderer in-process
13
+ - [Deploy consumes a built frontend, it does not build one](deploy-consumes-a-built-frontend.md) — pikku reads an already-built client directory named by the frontend config key; running the frontend's build command is the project's job
14
+ - [Desktop builds are unsigned and never update themselves](desktop-builds-are-unsigned-and-never-update-themselves.md) — Code signing, notarization and auto-update are deliberately absent from the first version of the Tauri shell — a known limitation, not an oversight
15
+ - [Standalone assets are embedded in the bun binary](standalone-assets-are-embedded-in-the-bun-binary.md) — A generated manifest of `with { type: 'file' }` imports puts the frontend inside the compiled binary; the imports must be static literals, which fixes the build order
16
+ - [The desktop shell runs the server as a sidecar, not embedded](the-desktop-shell-runs-the-server-as-a-sidecar.md) — Tauri spawns the compiled pikku binary and points the webview at its HTTP origin, so cookies, CORS and OAuth behave exactly as they do in a browser
17
+ - [The sidecar reports its port; the shell never picks one](the-sidecar-reports-its-port-the-shell-never-picks-one.md) — The server binds :0 and prints the port it got on the ready line; Rust blocks on that line rather than choosing a free port and passing it down
18
+
19
+ <!-- /pikku:knowledge-index -->
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: decision
3
+ title: Standalone assets are embedded in the bun binary
4
+ description: A generated manifest of `with { type: 'file' }` imports puts the frontend inside the compiled binary; the imports must be static literals, which fixes the build order
5
+ tags: [frontend, standalone, bun, assets]
6
+ ---
7
+
8
+ # Standalone assets are embedded in the bun binary
9
+
10
+ On the bun runtime the frontend ships **inside** the compiled binary rather than
11
+ beside it. `bun build --compile` embeds any module imported with
12
+ `with { type: 'file' }`, and `Bun.file()` reads the embedded path back at
13
+ runtime. That is what makes the artifact a single file you can hand someone,
14
+ which was the whole premise of the feature.
15
+
16
+ This was verified rather than assumed: a test binary was compiled, its `assets/`
17
+ directory and entry source deleted from disk, and the binary then served both
18
+ `/` and a hashed asset correctly, with `Bun.embeddedFiles` reporting both files.
19
+ Two things fell out of that check. Content types are inferred by `Bun.file` for
20
+ free, so the bun path needs no MIME table of its own. And the binary is large —
21
+ around 64MB for a trivial app — because the bun runtime is in there; that is the
22
+ cost of the single-file property, not a bug to optimize away.
23
+
24
+ The constraint that shapes the code is that **`with { type: 'file' }` cannot be
25
+ dynamic**. There is no way to embed a directory, or to build the import list at
26
+ runtime; each file needs its own literal `import` statement. So a generated
27
+ manifest module enumerates them, and generating it requires the built frontend
28
+ to already exist — see
29
+ [deploy consumes a built frontend](deploy-consumes-a-built-frontend.md).
30
+
31
+ Because assets live in the binary on one runtime and on disk on another,
32
+ `StaticMount` carries an optional `assets: Record<string, string>` map. A mount
33
+ with `assets` resolves keys through it; a mount without one resolves them against
34
+ `directory`. One mount type, one pipeline, and the node path stays exactly as it
35
+ was.
36
+
37
+ **What this rules out:** shipping the frontend as a sibling directory next to the
38
+ binary, a runtime-assembled embed list, and a second static-serving code path for
39
+ embedded assets.
@@ -0,0 +1,51 @@
1
+ ---
2
+ type: decision
3
+ title: The desktop shell runs the server as a sidecar, not embedded
4
+ description: Tauri spawns the compiled pikku binary and points the webview at its HTTP origin, so cookies, CORS and OAuth behave exactly as they do in a browser
5
+ tags: [tauri, desktop, standalone, bun]
6
+ ---
7
+
8
+ # The desktop shell runs the server as a sidecar, not embedded
9
+
10
+ `pikku deploy apply --provider standalone --runtime bun --desktop` generates a
11
+ `src-tauri/` crate that ships the compiled binary as an `externalBin`, spawns it
12
+ at launch, and opens a window at `http://127.0.0.1:<port>`. The server serves
13
+ both the API and the built frontend, so **the UI and the API share one real HTTP
14
+ origin**.
15
+
16
+ That single property is the whole reason for the design. A webview loaded from
17
+ `tauri://localhost` is a different origin from the server it talks to, and
18
+ everything keyed on `window.location.origin` breaks: cookies stop being
19
+ first-party, every request needs CORS, better-auth needs special-casing, and
20
+ OAuth redirects have nowhere valid to land. Pointing the webview at the server's
21
+ own origin means none of that is true — the app is the same app it is on the
22
+ web, and no auth code knows it is running on a desktop.
23
+
24
+ The user never writes Rust. `main.rs`, `tauri.conf.json`, `Cargo.toml`,
25
+ `build.rs`, a placeholder icon and a placeholder frontend directory are all
26
+ generated, with the product name and bundle identifier taken from the project
27
+ rather than hardcoded. Regenerating is idempotent, and a file the user has since
28
+ edited is left alone and reported rather than overwritten — the generator
29
+ records a hash of what it wrote, which is the only way to tell "unchanged since
30
+ we wrote it" from "the user has taken this over".
31
+
32
+ Two supporting rules fall out of running a real server process:
33
+
34
+ - **Single instance is load-bearing.** `tauri-plugin-single-instance` focuses the
35
+ existing window instead of launching again. Two shells would mean two
36
+ sidecars: two SQLite writers on one file.
37
+ - **The sidecar must not outlive the shell.** Tauri stops it on a clean exit, but
38
+ a hard crash never runs that path, and an orphan holds the database open. The
39
+ shell passes its pid down as
40
+ `PIKKU_PARENT_PID` and the server polls it, exiting when the parent is gone.
41
+ With no such variable set — a terminal, a container — the watch is inert.
42
+
43
+ The shell also resolves the platform's app-data directory and passes it as
44
+ `PIKKU_DATA_DIR`. A double-clicked app has no meaningful working directory, so
45
+ that variable is where the SQLite file, uploaded content and runtime state live;
46
+ the server reads it in bootstrap, which is the one place `process.env` is
47
+ allowed.
48
+
49
+ **What this rules out:** linking the server into the Rust binary, serving the UI
50
+ from `tauri://localhost` or a custom protocol, a second auth path for desktop
51
+ builds, and any design where two windows can be open at once.
@@ -0,0 +1,44 @@
1
+ ---
2
+ type: decision
3
+ title: The sidecar reports its port; the shell never picks one
4
+ description: The server binds :0 and prints the port it got on the ready line; Rust blocks on that line rather than choosing a free port and passing it down
5
+ tags: [tauri, desktop, ports, server-ready]
6
+ ---
7
+
8
+ # The sidecar reports its port; the shell never picks one
9
+
10
+ The shell starts the sidecar with `PORT=0`, reads its stdout until the ready
11
+ line appears, parses the port out of it, and only then creates the window.
12
+
13
+ The obvious alternative — have Rust find a free port and pass it down — has a
14
+ race with no fix: between the check that a port is free and the sidecar's bind,
15
+ anything on the machine can take it. Binding first and reporting back is the
16
+ only ordering with no window in it.
17
+
18
+ The line it waits for is the existing readiness marker:
19
+
20
+ ```
21
+ pikku: ready on http://127.0.0.1:53422
22
+ ```
23
+
24
+ `SERVER_READY_MARKER` now lives in `@pikku/deploy` rather than in the CLI,
25
+ because the two ends of the handshake are built by different packages: the CLI
26
+ waits on it for `pikku dev --spawn`, and the standalone provider's generated
27
+ entry prints it from inside the shipped binary. The CLI re-exports it from its
28
+ old path, so nothing that imported it had to change. A second copy of the string
29
+ would have drifted the first time either side touched it.
30
+
31
+ Making the line true required a real bound port to report. `--port 0` used to
32
+ print `:0`, because both `serve` and `dev` logged the *requested* port. Both
33
+ runtimes now expose the port they actually bound —
34
+ `PikkuNodeHTTPServer.port` reads `server.address()`, `PikkuBunServer.port`
35
+ already had it — `DevServerInstance` carries it, and every URL announced after
36
+ `start()` is built from it.
37
+
38
+ Note that `listening on …` is still **not** readiness. It is printed inside
39
+ `server.start()`, before the project's `afterStart` has run, so a parent that
40
+ treats it as ready races whatever the project seeds there.
41
+
42
+ **What this rules out:** a fixed default port for desktop builds, port
43
+ allocation in Rust, a handshake over a file or a socket instead of stdout, and
44
+ treating the runtime's own `listening on …` line as ready.
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: overview
3
+ title: Knowledge
4
+ description: How a standalone unit comes to contain a frontend as well as a server, and how it becomes a double-clickable desktop app
5
+ ---
6
+
7
+ # Knowledge
8
+
9
+ A standalone unit is meant to be one artifact you can hand someone: a binary
10
+ that, when run, is the whole application. Adding a UI to that picture forces
11
+ three questions — what kind of frontend, who builds it, and where the files
12
+ live at runtime — and the answers are less obvious than they look.
13
+
14
+ Wrapping that binary in a window forces a second set: who starts the server,
15
+ how the window learns the port it bound, who holds the passphrase, and what
16
+ happens to the server when the window goes away.
17
+
18
+ These notes record the answers and the constraints that produced them.
19
+
20
+ <!-- pikku:knowledge-index -->
21
+ - [decisions](decisions/index.md) — a rule that was chosen, and what it rules out
22
+ <!-- /pikku:knowledge-index -->
package/package.json CHANGED
@@ -1,18 +1,23 @@
1
1
  {
2
2
  "name": "@pikku/deploy-standalone",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "description": "Standalone deploy adapter for Pikku — bundles a project into a node bundle or a compiled bun executable",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
8
8
  "build": "tsc -b",
9
- "tsc": "tsc --noEmit"
9
+ "tsc": "tsc --noEmit",
10
+ "test": "node --test --import tsx src/*.test.ts"
10
11
  },
11
12
  "devDependencies": {
12
13
  "typescript": "^6.0.3"
13
14
  },
14
15
  "exports": {
15
- ".": "./dist/index.js"
16
+ ".": "./dist/index.js",
17
+ "./runtime": "./dist/runtime/index.js"
16
18
  },
17
- "license": "MIT"
18
- }
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "@pikku/deploy": "^0.12.2"
22
+ }
23
+ }
@@ -0,0 +1,186 @@
1
+ import { after, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { existsSync, rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import { StandaloneProviderAdapter } from './adapter.js'
9
+
10
+ const baseContext = {
11
+ unit: { name: 'app', role: 'function' },
12
+ unitDir: '/build/app',
13
+ bootstrapPath: './.pikku/pikku-bootstrap.gen.js',
14
+ configImport: `import { createConfig } from './config.js'`,
15
+ configVar: 'createConfig',
16
+ servicesImport: `import { createSingletonServices } from './services.js'`,
17
+ servicesVar: 'createSingletonServices',
18
+ singletonServicesImport: '',
19
+ servicesType: 'Record<string, unknown>',
20
+ mcpImport: '',
21
+ mcpServerOption: '',
22
+ } as never
23
+
24
+ const withFrontend = {
25
+ ...(baseContext as object),
26
+ frontend: { urlPrefix: '/', spaFallback: true },
27
+ } as never
28
+
29
+ describe('StandaloneProviderAdapter frontend serving', () => {
30
+ test('a node entry without a frontend mounts nothing', () => {
31
+ const source = new StandaloneProviderAdapter({
32
+ runtime: 'node',
33
+ }).generateEntrySource(baseContext)
34
+
35
+ assert.doesNotMatch(source, /staticMounts/)
36
+ })
37
+
38
+ test('a bun entry without a frontend imports no asset manifest', () => {
39
+ const source = new StandaloneProviderAdapter({
40
+ runtime: 'bun',
41
+ }).generateEntrySource(baseContext)
42
+
43
+ assert.doesNotMatch(source, /frontend-assets/)
44
+ assert.doesNotMatch(source, /staticMounts/)
45
+ })
46
+
47
+ test('a node entry serves the frontend from a directory beside the bundle', () => {
48
+ const source = new StandaloneProviderAdapter({
49
+ runtime: 'node',
50
+ }).generateEntrySource(withFrontend)
51
+
52
+ assert.match(source, /staticMounts/)
53
+ assert.match(
54
+ source,
55
+ /import\.meta\.url/,
56
+ 'the directory must be resolved from the running bundle, not the build machine'
57
+ )
58
+ assert.doesNotMatch(
59
+ source,
60
+ /assets:/,
61
+ 'node reads the copied directory rather than an embed map'
62
+ )
63
+ })
64
+
65
+ test('a bun entry serves the frontend from the embedded asset map', () => {
66
+ const source = new StandaloneProviderAdapter({
67
+ runtime: 'bun',
68
+ }).generateEntrySource(withFrontend)
69
+
70
+ assert.match(source, /from '\.\/frontend-assets\.gen\.js'/)
71
+ assert.match(source, /assets:/)
72
+ assert.doesNotMatch(
73
+ source,
74
+ /import\.meta\.url/,
75
+ 'an embedded asset has no directory to resolve'
76
+ )
77
+ })
78
+
79
+ test('the mount carries the configured prefix and fallback', () => {
80
+ const source = new StandaloneProviderAdapter({
81
+ runtime: 'bun',
82
+ }).generateEntrySource({
83
+ ...(baseContext as object),
84
+ frontend: { urlPrefix: '/app', spaFallback: false },
85
+ } as never)
86
+
87
+ assert.match(source, /urlPrefix: '\/app'/)
88
+ assert.match(source, /spaFallback: false/)
89
+ })
90
+
91
+ test('bun externalises the asset manifest so esbuild never parses it', () => {
92
+ // esbuild rejects `with { type: 'file' }` outright; the manifest has to
93
+ // survive to the `bun build --compile` step untouched.
94
+ const externals = new StandaloneProviderAdapter({
95
+ runtime: 'bun',
96
+ }).getExternals()
97
+
98
+ assert.ok(externals.includes('./frontend-assets.gen.js'))
99
+ })
100
+
101
+ test('the node runtime has no manifest to externalise', () => {
102
+ const externals = new StandaloneProviderAdapter({
103
+ runtime: 'node',
104
+ }).getExternals()
105
+
106
+ assert.ok(!externals.includes('./frontend-assets.gen.js'))
107
+ })
108
+ })
109
+
110
+ describe('StandaloneProviderAdapter deploy output', () => {
111
+ const tempDirs: string[] = []
112
+
113
+ after(() => {
114
+ for (const dir of tempDirs) {
115
+ rmSync(dir, { recursive: true, force: true })
116
+ }
117
+ })
118
+
119
+ const builtUnit = async (options: { withFrontend: boolean }) => {
120
+ const buildDir = await mkdtemp(join(tmpdir(), 'pikku-standalone-'))
121
+ tempDirs.push(buildDir)
122
+ const unitDir = join(buildDir, 'app')
123
+ await mkdir(unitDir, { recursive: true })
124
+ await writeFile(join(unitDir, 'bundle.js'), 'console.log("bundle")')
125
+ if (options.withFrontend) {
126
+ await mkdir(join(unitDir, 'frontend', 'assets'), { recursive: true })
127
+ await writeFile(
128
+ join(unitDir, 'frontend', 'index.html'),
129
+ '<!doctype html>'
130
+ )
131
+ await writeFile(join(unitDir, 'frontend', 'assets', 'app.js'), 'app')
132
+ await writeFile(
133
+ join(unitDir, 'frontend-assets.gen.js'),
134
+ 'export const frontendAssets = {}\n'
135
+ )
136
+ }
137
+ return { buildDir, outDir: join(buildDir, 'app-dist') }
138
+ }
139
+
140
+ const silentLogger = { info: () => {}, error: () => {} }
141
+
142
+ test('ships the frontend beside the bundle', async () => {
143
+ // The node entry resolves its mount directory relative to itself, so the
144
+ // copy has to land in the distributable, not just in the build directory.
145
+ const { buildDir, outDir } = await builtUnit({ withFrontend: true })
146
+
147
+ const result = await new StandaloneProviderAdapter({
148
+ runtime: 'node',
149
+ }).deploy({ buildDir, logger: silentLogger })
150
+
151
+ assert.equal(result.success, true)
152
+ assert.equal(
153
+ await readFile(join(outDir, 'frontend', 'assets', 'app.js'), 'utf-8'),
154
+ 'app'
155
+ )
156
+ })
157
+
158
+ test('ships the asset manifest the compile step still has to resolve', async () => {
159
+ // `bun build --compile` follows the import out of the copied bundle, so the
160
+ // manifest has to sit next to it — it was excluded from the esbuild output
161
+ // precisely so it would still be a real file at this point.
162
+ const { buildDir, outDir } = await builtUnit({ withFrontend: true })
163
+
164
+ await new StandaloneProviderAdapter({ runtime: 'node' }).deploy({
165
+ buildDir,
166
+ logger: silentLogger,
167
+ })
168
+
169
+ assert.match(
170
+ await readFile(join(outDir, 'frontend-assets.gen.js'), 'utf-8'),
171
+ /frontendAssets/
172
+ )
173
+ })
174
+
175
+ test('a project without a frontend copies nothing extra', async () => {
176
+ const { buildDir, outDir } = await builtUnit({ withFrontend: false })
177
+
178
+ const result = await new StandaloneProviderAdapter({
179
+ runtime: 'node',
180
+ }).deploy({ buildDir, logger: silentLogger })
181
+
182
+ assert.equal(result.success, true)
183
+ assert.equal(existsSync(join(outDir, 'frontend')), false)
184
+ assert.equal(existsSync(join(outDir, 'frontend-assets.gen.js')), false)
185
+ })
186
+ })