@pikku/deploy-standalone 0.12.12 → 0.12.17
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.
- package/CHANGELOG.md +177 -0
- package/dist/adapter.d.ts +45 -0
- package/dist/adapter.js +448 -6
- package/dist/runtime/cli.d.ts +75 -0
- package/dist/runtime/cli.js +193 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/parent-watch.d.ts +45 -0
- package/dist/runtime/parent-watch.js +87 -0
- package/dist/tauri/generate.d.ts +45 -0
- package/dist/tauri/generate.js +230 -0
- package/dist/tauri/icon.d.ts +1 -0
- package/dist/tauri/icon.js +54 -0
- package/dist/tauri/main-rs.d.ts +31 -0
- package/dist/tauri/main-rs.js +213 -0
- package/dist/tauri/next-steps.d.ts +15 -0
- package/dist/tauri/next-steps.js +16 -0
- package/dist/tauri/target-triple.d.ts +29 -0
- package/dist/tauri/target-triple.js +42 -0
- package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
- package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
- package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
- package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
- package/knowledge/decisions/index.md +19 -0
- package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
- package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
- package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
- package/knowledge/index.md +22 -0
- package/package.json +7 -4
- package/src/adapter.test.ts +725 -0
- package/src/adapter.ts +508 -6
- package/src/desktop-deploy.test.ts +167 -0
- package/src/runtime/cli.test.ts +222 -0
- package/src/runtime/cli.ts +311 -0
- package/src/runtime/index.ts +31 -0
- package/src/runtime/parent-watch.process.test.ts +112 -0
- package/src/runtime/parent-watch.test.ts +148 -0
- package/src/runtime/parent-watch.ts +115 -0
- package/src/sidecar-entry.test.ts +89 -0
- package/src/tauri/generate.test.ts +401 -0
- package/src/tauri/generate.ts +327 -0
- package/src/tauri/icon.test.ts +63 -0
- package/src/tauri/icon.ts +62 -0
- package/src/tauri/main-rs.rustfmt.test.ts +86 -0
- package/src/tauri/main-rs.ts +241 -0
- package/src/tauri/next-steps.test.ts +38 -0
- package/src/tauri/next-steps.ts +30 -0
- package/src/tauri/target-triple.test.ts +84 -0
- package/src/tauri/target-triple.ts +65 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type WindowOptions = {
|
|
2
|
+
windowTitle: string;
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
};
|
|
6
|
+
export type MainRsOptions = WindowOptions & ({
|
|
7
|
+
/** The `externalBin` base name — how `shell().sidecar(..)` finds the binary. */
|
|
8
|
+
sidecarName: string;
|
|
9
|
+
remoteUrl?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
/** An already-running server the window opens against. */
|
|
12
|
+
remoteUrl: string;
|
|
13
|
+
sidecarName?: undefined;
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* The shell's whole program.
|
|
17
|
+
*
|
|
18
|
+
* Everything it does follows from one decision: the server serves the UI and
|
|
19
|
+
* the API from a single real HTTP origin, and the webview simply points at it.
|
|
20
|
+
* A `tauri://localhost` webview would break every cookie, CORS check and OAuth
|
|
21
|
+
* redirect keyed on `window.location.origin`, which is the trap this design
|
|
22
|
+
* exists to avoid.
|
|
23
|
+
*
|
|
24
|
+
* Which origin that is decides the program. A sidecar binds its port at startup,
|
|
25
|
+
* so the shell has to start it, read the port off its ready line and open the
|
|
26
|
+
* window itself. A remote url is known before a line of Rust is generated, so
|
|
27
|
+
* the window is declared in `tauri.conf.json` and nothing here has to run at
|
|
28
|
+
* all beyond keeping the app to a single instance.
|
|
29
|
+
*/
|
|
30
|
+
export declare const renderMainRs: (options: MainRsOptions) => string;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { SERVER_READY_MARKER } from '@pikku/deploy';
|
|
2
|
+
import { DATA_DIR_ENV, PARENT_PID_ENV } from '../runtime/parent-watch.js';
|
|
3
|
+
/**
|
|
4
|
+
* The shell's whole program.
|
|
5
|
+
*
|
|
6
|
+
* Everything it does follows from one decision: the server serves the UI and
|
|
7
|
+
* the API from a single real HTTP origin, and the webview simply points at it.
|
|
8
|
+
* A `tauri://localhost` webview would break every cookie, CORS check and OAuth
|
|
9
|
+
* redirect keyed on `window.location.origin`, which is the trap this design
|
|
10
|
+
* exists to avoid.
|
|
11
|
+
*
|
|
12
|
+
* Which origin that is decides the program. A sidecar binds its port at startup,
|
|
13
|
+
* so the shell has to start it, read the port off its ready line and open the
|
|
14
|
+
* window itself. A remote url is known before a line of Rust is generated, so
|
|
15
|
+
* the window is declared in `tauri.conf.json` and nothing here has to run at
|
|
16
|
+
* all beyond keeping the app to a single instance.
|
|
17
|
+
*/
|
|
18
|
+
export const renderMainRs = (options) => options.remoteUrl === undefined
|
|
19
|
+
? renderSidecarMainRs(options)
|
|
20
|
+
: renderRemoteMainRs();
|
|
21
|
+
const renderRemoteMainRs = () => `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
|
|
22
|
+
// will not overwrite this file once you have changed it.
|
|
23
|
+
//
|
|
24
|
+
// The window is declared in tauri.conf.json, pointed straight at the remote
|
|
25
|
+
// server: its origin is known up front, so there is no port to discover and no
|
|
26
|
+
// process to supervise. The webview loads that origin rather than
|
|
27
|
+
// \`tauri://localhost\`, which is what keeps cookies first-party and OAuth
|
|
28
|
+
// redirects working.
|
|
29
|
+
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
30
|
+
|
|
31
|
+
use tauri::Manager;
|
|
32
|
+
|
|
33
|
+
fn main() {
|
|
34
|
+
tauri::Builder::default()
|
|
35
|
+
// A second launch should reach the window that is already open rather
|
|
36
|
+
// than start a second session against the same server.
|
|
37
|
+
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
38
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
39
|
+
let _ = window.unminimize();
|
|
40
|
+
let _ = window.show();
|
|
41
|
+
let _ = window.set_focus();
|
|
42
|
+
}
|
|
43
|
+
}))
|
|
44
|
+
.run(tauri::generate_context!())
|
|
45
|
+
.expect("error while running the pikku desktop shell");
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
const renderSidecarMainRs = (options) => {
|
|
49
|
+
const { sidecarName, windowTitle, width, height } = options;
|
|
50
|
+
return `// Generated by \`pikku deploy apply --desktop\`. Safe to edit — the generator
|
|
51
|
+
// will not overwrite this file once you have changed it.
|
|
52
|
+
//
|
|
53
|
+
// The webview points at the sidecar's own HTTP origin rather than at
|
|
54
|
+
// \`tauri://localhost\`, so cookies are first-party, there is no CORS, and OAuth
|
|
55
|
+
// redirects land where the server expects. The sidecar is the application; this
|
|
56
|
+
// shell only supervises it.
|
|
57
|
+
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
58
|
+
|
|
59
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
60
|
+
use std::sync::{Arc, Mutex};
|
|
61
|
+
use std::time::Duration;
|
|
62
|
+
|
|
63
|
+
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
|
64
|
+
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
|
65
|
+
use tauri_plugin_shell::ShellExt;
|
|
66
|
+
|
|
67
|
+
/// Printed by the sidecar once it is listening *and* its startup lifecycle has
|
|
68
|
+
/// finished. The runtime's own "listening on ..." line comes earlier and is not
|
|
69
|
+
/// readiness.
|
|
70
|
+
const READY_PREFIX: &str = "${SERVER_READY_MARKER} on http://";
|
|
71
|
+
|
|
72
|
+
/// How long to wait for the ready line before giving up. A sidecar that starts
|
|
73
|
+
/// and then hangs prints nothing, so no window is ever built — and a Tauri
|
|
74
|
+
/// process with no window cannot be quit from the dock or the taskbar.
|
|
75
|
+
const READY_TIMEOUT: Duration = Duration::from_secs(30);
|
|
76
|
+
|
|
77
|
+
/// Held so the child is not dropped while the app runs, and so a clean exit can
|
|
78
|
+
/// stop it explicitly.
|
|
79
|
+
struct Sidecar(Mutex<Option<CommandChild>>);
|
|
80
|
+
|
|
81
|
+
fn parse_ready_port(line: &str) -> Option<u16> {
|
|
82
|
+
let rest = line.split(READY_PREFIX).nth(1)?;
|
|
83
|
+
let authority = rest.split_whitespace().next()?;
|
|
84
|
+
authority
|
|
85
|
+
.trim_end_matches('/')
|
|
86
|
+
.rsplit(':')
|
|
87
|
+
.next()?
|
|
88
|
+
.parse()
|
|
89
|
+
.ok()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn main() {
|
|
93
|
+
tauri::Builder::default()
|
|
94
|
+
// A second launch must never spawn a second sidecar: that would be two
|
|
95
|
+
// SQLite writers on one file. Focus what is already running instead.
|
|
96
|
+
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
97
|
+
if let Some(window) = app.get_webview_window("main") {
|
|
98
|
+
let _ = window.unminimize();
|
|
99
|
+
let _ = window.show();
|
|
100
|
+
let _ = window.set_focus();
|
|
101
|
+
}
|
|
102
|
+
}))
|
|
103
|
+
.plugin(tauri_plugin_shell::init())
|
|
104
|
+
.setup(|app| {
|
|
105
|
+
let data_dir = app.path().app_data_dir()?;
|
|
106
|
+
std::fs::create_dir_all(&data_dir)?;
|
|
107
|
+
|
|
108
|
+
let (mut rx, child) = app
|
|
109
|
+
.shell()
|
|
110
|
+
.sidecar("${sidecarName}")?
|
|
111
|
+
// Port 0 asks the OS for a free port. Choosing one here and
|
|
112
|
+
// passing it down would race whatever binds it in between, so
|
|
113
|
+
// the sidecar binds first and reports back on its ready line.
|
|
114
|
+
.env("PORT", "0")
|
|
115
|
+
.env("HOST", "127.0.0.1")
|
|
116
|
+
// The database, uploaded content and runtime state live here
|
|
117
|
+
// and nowhere else. A double-clicked app has no meaningful
|
|
118
|
+
// working directory, so the platform's app-data path is the
|
|
119
|
+
// only sane answer — the server reads this in bootstrap.
|
|
120
|
+
.env("${DATA_DIR_ENV}", data_dir.to_string_lossy().to_string())
|
|
121
|
+
// Tauri stops the sidecar on a clean exit, but a hard crash of
|
|
122
|
+
// this process never runs that path, and an orphan would hold
|
|
123
|
+
// the database open with its data key still resident.
|
|
124
|
+
.env("${PARENT_PID_ENV}", std::process::id().to_string())
|
|
125
|
+
.spawn()?;
|
|
126
|
+
|
|
127
|
+
app.manage(Sidecar(Mutex::new(Some(child))));
|
|
128
|
+
|
|
129
|
+
let opened = Arc::new(AtomicBool::new(false));
|
|
130
|
+
|
|
131
|
+
let timeout_handle = app.handle().clone();
|
|
132
|
+
let timeout_opened = opened.clone();
|
|
133
|
+
std::thread::spawn(move || {
|
|
134
|
+
std::thread::sleep(READY_TIMEOUT);
|
|
135
|
+
if !timeout_opened.load(Ordering::SeqCst) {
|
|
136
|
+
eprintln!(
|
|
137
|
+
"the pikku sidecar did not become ready within {:?} — giving up",
|
|
138
|
+
READY_TIMEOUT
|
|
139
|
+
);
|
|
140
|
+
timeout_handle.exit(1);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let handle = app.handle().clone();
|
|
145
|
+
tauri::async_runtime::spawn(async move {
|
|
146
|
+
while let Some(event) = rx.recv().await {
|
|
147
|
+
let line = match event {
|
|
148
|
+
CommandEvent::Stdout(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
149
|
+
CommandEvent::Stderr(bytes) => {
|
|
150
|
+
eprint!("{}", String::from_utf8_lossy(&bytes));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
CommandEvent::Terminated(payload) => {
|
|
154
|
+
eprintln!("pikku sidecar exited: {:?}", payload.code);
|
|
155
|
+
if !opened.load(Ordering::SeqCst) {
|
|
156
|
+
handle.exit(1);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
_ => continue,
|
|
161
|
+
};
|
|
162
|
+
print!("{}", line);
|
|
163
|
+
|
|
164
|
+
if opened.load(Ordering::SeqCst) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if let Some(port) = parse_ready_port(&line) {
|
|
168
|
+
let url = format!("http://127.0.0.1:{}", port);
|
|
169
|
+
match url.parse() {
|
|
170
|
+
Ok(parsed) => {
|
|
171
|
+
let built = WebviewWindowBuilder::new(
|
|
172
|
+
&handle,
|
|
173
|
+
"main",
|
|
174
|
+
WebviewUrl::External(parsed),
|
|
175
|
+
)
|
|
176
|
+
.title("${windowTitle}")
|
|
177
|
+
.inner_size(${width}.0, ${height}.0)
|
|
178
|
+
.build();
|
|
179
|
+
match built {
|
|
180
|
+
Ok(_) => opened.store(true, Ordering::SeqCst),
|
|
181
|
+
Err(err) => {
|
|
182
|
+
eprintln!("could not open the window: {}", err);
|
|
183
|
+
handle.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
Err(err) => {
|
|
188
|
+
eprintln!("the sidecar reported an unusable url: {}", err);
|
|
189
|
+
handle.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
Ok(())
|
|
197
|
+
})
|
|
198
|
+
.on_window_event(|window, event| {
|
|
199
|
+
if let tauri::WindowEvent::Destroyed = event {
|
|
200
|
+
if let Some(sidecar) = window.app_handle().try_state::<Sidecar>() {
|
|
201
|
+
if let Ok(mut guard) = sidecar.0.lock() {
|
|
202
|
+
if let Some(child) = guard.take() {
|
|
203
|
+
let _ = child.kill();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
.run(tauri::generate_context!())
|
|
210
|
+
.expect("error while running the pikku desktop shell");
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TauriNextStepsOptions = {
|
|
2
|
+
/** Absolute path of the generated crate. */
|
|
3
|
+
shellDir: string;
|
|
4
|
+
/** Whether a Rust toolchain answered when the triple was resolved. */
|
|
5
|
+
hasRust: boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* What to say once the crate and its sidecar are on disk.
|
|
9
|
+
*
|
|
10
|
+
* Generation is pure Node — it writes files and copies a binary — so `--desktop`
|
|
11
|
+
* succeeds perfectly well on a machine that cannot build the result. Saying so
|
|
12
|
+
* here is the difference between a known prerequisite and a cargo error at the
|
|
13
|
+
* point someone least expects one.
|
|
14
|
+
*/
|
|
15
|
+
export declare const renderTauriNextSteps: ({ shellDir, hasRust, }: TauriNextStepsOptions) => string[];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const PREREQUISITES = 'https://tauri.app/start/prerequisites/';
|
|
2
|
+
/**
|
|
3
|
+
* What to say once the crate and its sidecar are on disk.
|
|
4
|
+
*
|
|
5
|
+
* Generation is pure Node — it writes files and copies a binary — so `--desktop`
|
|
6
|
+
* succeeds perfectly well on a machine that cannot build the result. Saying so
|
|
7
|
+
* here is the difference between a known prerequisite and a cargo error at the
|
|
8
|
+
* point someone least expects one.
|
|
9
|
+
*/
|
|
10
|
+
export const renderTauriNextSteps = ({ shellDir, hasRust, }) => {
|
|
11
|
+
const lines = [` Next: cd ${shellDir} && npx tauri build`];
|
|
12
|
+
if (!hasRust) {
|
|
13
|
+
lines.push(` That step needs a Rust toolchain, and none answered here — see ${PREREQUISITES}.`, ' The crate and its sidecar are complete, so another machine can build them as they are.');
|
|
14
|
+
}
|
|
15
|
+
return lines;
|
|
16
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
export type HostPlatform = NodeJS.Platform;
|
|
11
|
+
export type HostArch = string;
|
|
12
|
+
/** Pull the `host:` line out of `rustc -vV` output. */
|
|
13
|
+
export declare const parseRustcHost: (output: string) => string | undefined;
|
|
14
|
+
export type HostTargetTripleOptions = {
|
|
15
|
+
platform?: HostPlatform;
|
|
16
|
+
arch?: HostArch;
|
|
17
|
+
/** Raw `rustc -vV` output, when the toolchain was reachable. */
|
|
18
|
+
rustcVersionVerbose?: string;
|
|
19
|
+
};
|
|
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 declare const hostTargetTriple: (options?: HostTargetTripleOptions) => string;
|
|
28
|
+
/** `binaries/<name>-<triple>[.exe]`, exactly as `externalBin` looks it up. */
|
|
29
|
+
export declare const sidecarFileName: (baseName: string, targetTriple: string) => string;
|
|
@@ -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.
|