@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # @pikku/deploy-standalone
2
2
 
3
+ ## 0.12.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 80eb5c0: Generate a desktop shell from `pikku deploy apply --desktop`
8
+
9
+ `pikku deploy apply --provider standalone --runtime bun --desktop` now emits a
10
+ `src-tauri/` crate that ships the compiled binary as a sidecar and opens a
11
+ window on the server's own HTTP origin, so cookies, CORS and OAuth behave
12
+ exactly as they do in a browser. Regeneration is idempotent and leaves an
13
+ edited file alone rather than overwriting it.
14
+
15
+ `--desktop-url https://app.example.com` builds the other shape: a shell that
16
+ points at an already-deployed server. Nothing is bundled — no sidecar, no
17
+ binary, and so no bun runtime to compile one — and the window is declared in
18
+ `tauri.conf.json` rather than opened from Rust, because the origin is known up
19
+ front. The url can also live in `pikku.config.json` as `deploy.desktop.url`,
20
+ alongside `deploy.desktop.identifier`.
21
+
22
+ Supporting changes: `SERVER_READY_MARKER` moved to `@pikku/deploy` (the CLI
23
+ re-exports it from its old path), both HTTP runtimes expose the port they
24
+ actually bound so `--port 0` reports a real port, and the generated server
25
+ entry exits when its parent process goes away.
26
+
27
+ - 80eb5c0: feat: serve a built frontend from the pikku server's own origin
28
+
29
+ A new `frontend` key in `pikku.config.json` names a directory of built
30
+ frontend output. `pikku serve` mounts it, and `pikku deploy` ships it inside
31
+ the distributable — into a directory beside the bundle for the node runtime,
32
+ and embedded in the binary for a `bun build --compile` standalone. `pikku dev`
33
+ deliberately ignores it and says so, because the frontend's own dev server owns
34
+ that job.
35
+
36
+ Pikku reads the frontend's output and never builds it, so an unbuilt directory
37
+ fails with a message that says which build to run rather than booting a server
38
+ that answers every page with a 404.
39
+
40
+ - Updated dependencies [80eb5c0]
41
+ - Updated dependencies [80eb5c0]
42
+ - @pikku/deploy@0.12.2
43
+
44
+ ## 0.12.12
45
+
46
+ ### Patch Changes
47
+
48
+ - 32616af: Give the deploy pipeline one shared contract instead of a copy per adapter
49
+
50
+ `DeploymentManifest`, `DeploymentUnit`, `EntryGenerationContext` and
51
+ `ProviderAdapter` were hand-copied into eleven source files across the four
52
+ provider adapters and the CLI — three copies inside `@pikku/deploy-cloudflare`
53
+ alone. Nothing compared the copies, so they had already drifted: several typed
54
+ `role` as a bare `string`, and none carried the manifest's addon-scoping fields.
55
+
56
+ They now live in a new zero-dependency `@pikku/deploy` package that every
57
+ adapter and the CLI import, and each adapter declares `implements
58
+ ProviderAdapter` so the compiler checks it against the contract it claims to
59
+ satisfy. That check immediately caught a real disagreement: the deploy result's
60
+ `workersDeployed` and `resourcesCreated` were `string[]` from Cloudflare — the
61
+ shape the result file and the generated SDK types already record — but
62
+ `Array<{ name: string }>` from the standalone adapter. Both are now `string[]`.
63
+
64
+ The Lambda and Azure adapters also derived their esbuild externals from a
65
+ hand-written list of 25 node builtins, so anything outside it (`async_hooks`,
66
+ `perf_hooks`, `timers`, `http2`, …) was bundled instead of resolved from the
67
+ runtime. They now use `nodeBuiltinExternals()`, which reads `builtinModules`
68
+ from the running Node and cannot fall behind it.
69
+
70
+ - Updated dependencies [32616af]
71
+ - @pikku/deploy@0.12.1
72
+
3
73
  ## 0.12.11
4
74
 
5
75
  ### Patch Changes
package/dist/adapter.d.ts CHANGED
@@ -16,55 +16,48 @@
16
16
  * compiles the bundle into a single self-contained executable via
17
17
  * `bun build --compile`. No runtime needed on the target host.
18
18
  */
19
+ import type { EntryGenerationContext, ProviderAdapter } from '@pikku/deploy';
19
20
  export type StandaloneRuntime = 'node' | 'bun';
21
+ /**
22
+ * Directory the built frontend is copied to, both inside the unit and beside
23
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
24
+ * so the two have to agree.
25
+ */
26
+ export declare const STANDALONE_FRONTEND_DIR = "frontend";
27
+ /**
28
+ * Module the bun entry imports its embedded assets from. It stays out of the
29
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
30
+ * manifest is built on — and is resolved by `bun build --compile` instead.
31
+ */
32
+ export declare const STANDALONE_FRONTEND_MANIFEST = "./frontend-assets.gen.js";
20
33
  export interface StandaloneProviderAdapterOptions {
21
34
  runtime?: StandaloneRuntime;
35
+ /**
36
+ * Generate a desktop shell (Tauri) around the compiled binary. Requires the
37
+ * `bun` runtime — the shell ships the binary as a sidecar, and only that
38
+ * runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
39
+ * and so has no such requirement.
40
+ */
41
+ desktop?: boolean;
42
+ /** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
43
+ projectDir?: string;
44
+ /** Bundle identifier for the shell. Derived from the app name when absent. */
45
+ desktopIdentifier?: string;
46
+ /**
47
+ * An already-deployed server for the shell to open, instead of bundling one.
48
+ * The window is a webview onto that origin and nothing else is shipped.
49
+ */
50
+ desktopUrl?: string;
22
51
  }
23
- type DeploymentHandler = {
24
- type: 'fetch';
25
- routes: Array<{
26
- method: string;
27
- route: string;
28
- pikkuFuncId: string;
29
- }>;
30
- } | {
31
- type: 'queue';
32
- queueName: string;
33
- } | {
34
- type: 'scheduled';
35
- schedule: string;
36
- taskName: string;
37
- };
38
- interface DeploymentUnit {
39
- name: string;
40
- role: string;
41
- functionIds: string[];
42
- services: Array<{
43
- capability: string;
44
- sourceServiceName: string;
45
- }>;
46
- dependsOn: string[];
47
- handlers: DeploymentHandler[];
48
- tags: string[];
49
- }
50
- interface EntryGenerationContext {
51
- unit: DeploymentUnit;
52
- unitDir: string;
53
- bootstrapPath: string;
54
- configImport: string;
55
- configVar: string;
56
- servicesImport: string;
57
- servicesVar: string;
58
- singletonServicesImport: string;
59
- servicesType: string;
60
- mcpImport: string;
61
- mcpServerOption: string;
62
- }
63
- export declare class StandaloneProviderAdapter {
52
+ export declare class StandaloneProviderAdapter implements ProviderAdapter {
64
53
  readonly name = "standalone";
65
54
  readonly deployDirName = "standalone";
66
55
  readonly singleUnit = true;
67
56
  readonly runtime: StandaloneRuntime;
57
+ readonly desktop: boolean;
58
+ readonly projectDir?: string;
59
+ readonly desktopIdentifier?: string;
60
+ readonly desktopUrl?: string;
68
61
  constructor(options?: StandaloneProviderAdapterOptions);
69
62
  generateEntrySource(ctx: EntryGenerationContext): string;
70
63
  private generateNodeEntrySource;
@@ -89,13 +82,12 @@ export declare class StandaloneProviderAdapter {
89
82
  }[];
90
83
  workersDeployed?: undefined;
91
84
  resourcesCreated?: undefined;
85
+ targetTriple?: undefined;
92
86
  } | {
93
87
  success: boolean;
94
- workersDeployed: {
95
- name: string;
96
- }[];
88
+ workersDeployed: string[];
97
89
  resourcesCreated: never[];
98
90
  errors: never[];
91
+ targetTriple: string | undefined;
99
92
  }>;
100
93
  }
101
- export {};
package/dist/adapter.js CHANGED
@@ -1,28 +1,60 @@
1
+ import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy';
1
2
  /**
2
- * Standalone provider adapter for the Pikku deploy pipeline.
3
- *
4
- * Produces a self-contained distributable directory:
5
- *
6
- * {app-name}/
7
- * ├── bundle.js # esbuild bundle (all deps inlined)
8
- * ├── config/ # user config (env, secrets, etc.)
9
- *
10
- * Two runtimes, selected via `--runtime`:
3
+ * Directory the built frontend is copied to, both inside the unit and beside
4
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
5
+ * so the two have to agree.
6
+ */
7
+ export const STANDALONE_FRONTEND_DIR = 'frontend';
8
+ /**
9
+ * Module the bun entry imports its embedded assets from. It stays out of the
10
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
11
+ * manifest is built on — and is resolved by `bun build --compile` instead.
12
+ */
13
+ export const STANDALONE_FRONTEND_MANIFEST = './frontend-assets.gen.js';
14
+ /**
15
+ * Lines every standalone entry ends with, whatever the runtime.
11
16
  *
12
- * - `node` (default): uses `@pikku/node-http-server` (pure JS, node:http)
13
- * the same server `pikku dev` and the container deploy entry use, so all
14
- * three share one HTTP path. Ships `bundle.js`; run with `node bundle.js`.
15
- * - `bun`: uses `@pikku/bun-server` (Bun.serve, native WebSockets) and
16
- * compiles the bundle into a single self-contained executable via
17
- * `bun build --compile`. No runtime needed on the target host.
17
+ * The ready line is the handshake a parent process `pikku dev --spawn`, or
18
+ * the desktop shell that runs this binary as a sidecar blocks on. It carries
19
+ * `server.port` rather than the requested port because a shell passes `PORT=0`:
20
+ * picking a free port in the parent and handing it down races anything else
21
+ * that binds it in between, so the server binds first and reports back.
22
+ */
23
+ const sidecarHandshakeLines = () => [
24
+ ` watchParentProcess()`,
25
+ ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
26
+ ];
27
+ const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`;
28
+ /**
29
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
30
+ * back to the Node platform pair, which is right for every ordinary host — the
31
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
32
+ * toolchain is present anyway.
18
33
  */
34
+ const rustcHostOutput = async () => {
35
+ try {
36
+ const { execFileSync } = await import('node:child_process');
37
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' });
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ };
19
43
  export class StandaloneProviderAdapter {
20
44
  name = 'standalone';
21
45
  deployDirName = 'standalone';
22
46
  singleUnit = true;
23
47
  runtime;
48
+ desktop;
49
+ projectDir;
50
+ desktopIdentifier;
51
+ desktopUrl;
24
52
  constructor(options = {}) {
25
53
  this.runtime = options.runtime ?? 'node';
54
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl);
55
+ this.projectDir = options.projectDir;
56
+ this.desktopIdentifier = options.desktopIdentifier;
57
+ this.desktopUrl = options.desktopUrl;
26
58
  }
27
59
  generateEntrySource(ctx) {
28
60
  if (this.runtime === 'bun') {
@@ -35,12 +67,19 @@ export class StandaloneProviderAdapter {
35
67
  `// Generated standalone entry — all functions in one process`,
36
68
  `import { LocalEventHubService } from '@pikku/core/channel/local'`,
37
69
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
38
- `import { pikkuState } from '@pikku/core/ecosystem'`,
39
- `import { wireAgentScorerQueueWorkers } from '@pikku/core/ecosystem/agent-scorer'`,
70
+ `import { pikkuState } from '@pikku/core/state'`,
71
+ `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
40
72
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
41
73
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
42
74
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
43
75
  `import { WebSocketServer } from 'ws'`,
76
+ SIDECAR_RUNTIME_IMPORT,
77
+ ...(ctx.frontend
78
+ ? [
79
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
80
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
81
+ ]
82
+ : []),
44
83
  ``,
45
84
  ctx.configImport,
46
85
  ctx.servicesImport,
@@ -72,9 +111,21 @@ export class StandaloneProviderAdapter {
72
111
  ` })`,
73
112
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
74
113
  ``,
114
+ ...(ctx.frontend
115
+ ? [
116
+ // Resolved from the running bundle rather than baked in at build
117
+ // time, so the distributable stays movable.
118
+ ` const staticMounts = [{`,
119
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
120
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
121
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
122
+ ` }]`,
123
+ ``,
124
+ ]
125
+ : []),
75
126
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
76
127
  ` const server = new PikkuNodeHTTPServer(`,
77
- ` { ...config, port, hostname },`,
128
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
78
129
  ` logger,`,
79
130
  ` {`,
80
131
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -87,6 +138,7 @@ export class StandaloneProviderAdapter {
87
138
  ` await triggerService.start()`,
88
139
  ` server.enableExitOnSignals()`,
89
140
  ` await server.start()`,
141
+ ...sidecarHandshakeLines(),
90
142
  `}`,
91
143
  ``,
92
144
  `main().catch((err) => {`,
@@ -100,10 +152,14 @@ export class StandaloneProviderAdapter {
100
152
  return [
101
153
  `// Generated standalone entry (bun runtime) — all functions in one process`,
102
154
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
103
- `import { pikkuState } from '@pikku/core/ecosystem'`,
104
- `import { wireAgentScorerQueueWorkers } from '@pikku/core/ecosystem/agent-scorer'`,
155
+ SIDECAR_RUNTIME_IMPORT,
156
+ `import { pikkuState } from '@pikku/core/state'`,
157
+ `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
105
158
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
106
159
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
160
+ ...(ctx.frontend
161
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
162
+ : []),
107
163
  ``,
108
164
  ctx.configImport,
109
165
  ctx.servicesImport,
@@ -135,12 +191,26 @@ export class StandaloneProviderAdapter {
135
191
  ` })`,
136
192
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
137
193
  ``,
138
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
194
+ ...(ctx.frontend
195
+ ? [
196
+ // A compiled binary has no directory to read: every file was
197
+ // embedded, and the map is the only way back to it.
198
+ ` const staticMounts = [{`,
199
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
200
+ ` directory: '',`,
201
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
202
+ ` assets: frontendAssets,`,
203
+ ` }]`,
204
+ ``,
205
+ ]
206
+ : []),
207
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
139
208
  ` await server.init()`,
140
209
  ` await schedulerService.start()`,
141
210
  ` await triggerService.start()`,
142
211
  ` server.enableExitOnSignals()`,
143
212
  ` await server.start()`,
213
+ ...sidecarHandshakeLines(),
144
214
  `}`,
145
215
  ``,
146
216
  `main().catch((err) => {`,
@@ -160,33 +230,12 @@ export class StandaloneProviderAdapter {
160
230
  return new Map();
161
231
  }
162
232
  getExternals() {
163
- const externals = [
164
- 'node:*',
165
- 'child_process',
166
- 'crypto',
167
- 'fs',
168
- 'http',
169
- 'https',
170
- 'net',
171
- 'os',
172
- 'path',
173
- 'stream',
174
- 'url',
175
- 'util',
176
- 'zlib',
177
- 'events',
178
- 'buffer',
179
- 'querystring',
180
- 'tls',
181
- 'dns',
182
- 'dgram',
183
- 'cluster',
184
- 'worker_threads',
185
- ];
233
+ const externals = nodeBuiltinExternals();
186
234
  if (this.runtime === 'bun') {
187
235
  // Bun-native builtins are provided by the runtime and resolved by
188
236
  // `bun build --compile` — leave them as imports rather than inlining.
189
237
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi');
238
+ externals.push(STANDALONE_FRONTEND_MANIFEST);
190
239
  }
191
240
  return externals;
192
241
  }
@@ -195,8 +244,34 @@ export class StandaloneProviderAdapter {
195
244
  }
196
245
  async deploy(options) {
197
246
  const { buildDir, logger } = options;
247
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
248
+ // produce a shell should say so now, not after a bun compile.
249
+ if (this.desktop) {
250
+ if (!this.desktopUrl && this.runtime !== 'bun') {
251
+ return {
252
+ success: false,
253
+ errors: [
254
+ {
255
+ step: 'desktop',
256
+ error: `A desktop shell ships the server as a sidecar binary, which only the bun runtime produces. Re-run with --runtime bun (got '${this.runtime}').`,
257
+ },
258
+ ],
259
+ };
260
+ }
261
+ if (!this.projectDir) {
262
+ return {
263
+ success: false,
264
+ errors: [
265
+ {
266
+ step: 'desktop',
267
+ error: 'No project directory was supplied, so there is nowhere to write src-tauri/.',
268
+ },
269
+ ],
270
+ };
271
+ }
272
+ }
198
273
  const { join, dirname } = await import('node:path');
199
- const { readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
274
+ const { cp, readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
200
275
  const { existsSync } = await import('node:fs');
201
276
  // Find the unit dir with the bundle
202
277
  const entries = await readdir(buildDir);
@@ -218,6 +293,21 @@ export class StandaloneProviderAdapter {
218
293
  await copyFile(join(unitDir, 'bundle.js.map'), join(outDir, 'bundle.js.map'));
219
294
  }
220
295
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`);
296
+ // --- 2a. Frontend, when the build produced one ---
297
+ // Both runtimes need it here rather than only in the build directory: node
298
+ // resolves the mount relative to the shipped bundle, and `bun build
299
+ // --compile` follows the manifest import out of the copy it is given.
300
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR);
301
+ if (existsSync(frontendDir)) {
302
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
303
+ recursive: true,
304
+ });
305
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '');
306
+ if (existsSync(join(unitDir, manifestName))) {
307
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName));
308
+ }
309
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`);
310
+ }
221
311
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
222
312
  if (this.runtime === 'bun') {
223
313
  const { execFileSync } = await import('node:child_process');
@@ -245,6 +335,56 @@ export class StandaloneProviderAdapter {
245
335
  };
246
336
  }
247
337
  }
338
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
339
+ let targetTriple;
340
+ if (this.desktop && this.projectDir) {
341
+ const { generateTauriShell, tauriBundleIdentifier } = await import('./tauri/generate.js');
342
+ const { hostTargetTriple } = await import('./tauri/target-triple.js');
343
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js');
344
+ try {
345
+ const rustcVersionVerbose = await rustcHostOutput();
346
+ targetTriple = hostTargetTriple({ rustcVersionVerbose });
347
+ const shell = await generateTauriShell({
348
+ projectDir: this.projectDir,
349
+ appName,
350
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
351
+ targetTriple,
352
+ ...(this.desktopUrl
353
+ ? { remoteUrl: this.desktopUrl }
354
+ : { binaryPath: join(outDir, appName) }),
355
+ });
356
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`);
357
+ if (shell.written.length) {
358
+ logger.info(` wrote ${shell.written.join(', ')}`);
359
+ }
360
+ if (shell.preserved.length) {
361
+ logger.info(` kept your edits, not regenerated: ${shell.preserved.join(', ')}`);
362
+ }
363
+ if (shell.sidecar) {
364
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`);
365
+ }
366
+ else {
367
+ logger.info(` window opens: ${this.desktopUrl}`);
368
+ }
369
+ for (const line of renderTauriNextSteps({
370
+ shellDir: shell.dir,
371
+ hasRust: rustcVersionVerbose !== undefined,
372
+ })) {
373
+ logger.info(line);
374
+ }
375
+ }
376
+ catch (e) {
377
+ return {
378
+ success: false,
379
+ errors: [
380
+ {
381
+ step: 'desktop',
382
+ error: e instanceof Error ? e.message : String(e),
383
+ },
384
+ ],
385
+ };
386
+ }
387
+ }
248
388
  // --- 3. config/ — empty template with .env example ---
249
389
  const configDir = join(outDir, 'config');
250
390
  await mkdir(configDir, { recursive: true });
@@ -264,9 +404,10 @@ export class StandaloneProviderAdapter {
264
404
  logger.info(`Output: ${outDir}`);
265
405
  return {
266
406
  success: true,
267
- workersDeployed: [{ name: appName }],
407
+ workersDeployed: [appName],
268
408
  resourcesCreated: [],
269
409
  errors: [],
410
+ targetTriple,
270
411
  };
271
412
  }
272
413
  }
package/dist/index.d.ts CHANGED
@@ -11,5 +11,5 @@
11
11
  */
12
12
  import { StandaloneProviderAdapter, type StandaloneProviderAdapterOptions } from './adapter.js';
13
13
  export { StandaloneProviderAdapter };
14
- export type { StandaloneProviderAdapterOptions, } from './adapter.js';
14
+ export type { StandaloneProviderAdapterOptions } from './adapter.js';
15
15
  export declare const createAdapter: (options?: StandaloneProviderAdapterOptions) => StandaloneProviderAdapter;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
+ export type { ParentWatch, ParentWatchOptions } from './parent-watch.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export declare const PARENT_PID_ENV = "PIKKU_PARENT_PID";
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export declare const DATA_DIR_ENV = "PIKKU_DATA_DIR";
13
+ export type ParentWatchOptions = {
14
+ /** Where the parent pid is read from. Defaults to `process.env`. */
15
+ env?: Record<string, string | undefined>;
16
+ /** Probe for whether a pid is still running. Defaults to signal 0. */
17
+ isAlive?: (pid: number) => boolean;
18
+ /** Run when the parent is found to be gone. Defaults to exiting cleanly. */
19
+ onOrphaned?: () => void;
20
+ intervalMs?: number;
21
+ };
22
+ export type ParentWatch = {
23
+ /** False when no usable parent pid was supplied — the watch is inert. */
24
+ readonly watching: boolean;
25
+ readonly parentPid: number | undefined;
26
+ /** True only if the poll timer would hold the event loop open. */
27
+ readonly holdsProcessOpen: boolean;
28
+ /** Probe immediately rather than waiting for the next interval. */
29
+ checkNow(): void;
30
+ stop(): void;
31
+ };
32
+ /**
33
+ * Exit when the process that spawned us does.
34
+ *
35
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
36
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
37
+ * next launch — which single-instance only guards against a second *shell* —
38
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
39
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
40
+ * reliable across the platforms a desktop build targets.
41
+ *
42
+ * With no parent pid in the environment the watch is inert, so a server run
43
+ * from a terminal or a container behaves exactly as it did before.
44
+ */
45
+ export declare const watchParentProcess: (options?: ParentWatchOptions) => ParentWatch;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export const PARENT_PID_ENV = 'PIKKU_PARENT_PID';
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export const DATA_DIR_ENV = 'PIKKU_DATA_DIR';
13
+ /**
14
+ * A pid is alive if signalling it succeeds. `EPERM` also means alive — the
15
+ * process exists but belongs to another user — and only `ESRCH` means gone.
16
+ */
17
+ const defaultIsAlive = (pid) => {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ return err.code === 'EPERM';
24
+ }
25
+ };
26
+ const parsePid = (raw) => {
27
+ if (!raw)
28
+ return undefined;
29
+ if (!/^\d+$/.test(raw))
30
+ return undefined;
31
+ const pid = Number(raw);
32
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
33
+ };
34
+ /**
35
+ * Exit when the process that spawned us does.
36
+ *
37
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
38
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
39
+ * next launch — which single-instance only guards against a second *shell* —
40
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
41
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
42
+ * reliable across the platforms a desktop build targets.
43
+ *
44
+ * With no parent pid in the environment the watch is inert, so a server run
45
+ * from a terminal or a container behaves exactly as it did before.
46
+ */
47
+ export const watchParentProcess = (options = {}) => {
48
+ const env = options.env ?? process.env;
49
+ const isAlive = options.isAlive ?? defaultIsAlive;
50
+ const onOrphaned = options.onOrphaned ?? (() => process.exit(0));
51
+ const intervalMs = options.intervalMs ?? 1_000;
52
+ const parentPid = parsePid(env[PARENT_PID_ENV]);
53
+ let timer;
54
+ let fired = false;
55
+ const stop = () => {
56
+ if (timer) {
57
+ clearInterval(timer);
58
+ timer = undefined;
59
+ }
60
+ };
61
+ const checkNow = () => {
62
+ if (parentPid === undefined || fired)
63
+ return;
64
+ if (isAlive(parentPid))
65
+ return;
66
+ fired = true;
67
+ stop();
68
+ onOrphaned();
69
+ };
70
+ if (parentPid !== undefined) {
71
+ timer = setInterval(checkNow, intervalMs);
72
+ // The watch is a guard, not a reason to stay running: a server that has
73
+ // finished its work must still be allowed to exit.
74
+ timer.unref?.();
75
+ }
76
+ return {
77
+ get watching() {
78
+ return timer !== undefined;
79
+ },
80
+ parentPid,
81
+ get holdsProcessOpen() {
82
+ return timer?.hasRef?.() ?? false;
83
+ },
84
+ checkNow,
85
+ stop,
86
+ };
87
+ };