@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/src/adapter.ts CHANGED
@@ -16,53 +16,92 @@
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'
20
+ import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy'
19
21
 
20
22
  export type StandaloneRuntime = 'node' | 'bun'
21
23
 
22
- export interface StandaloneProviderAdapterOptions {
23
- runtime?: StandaloneRuntime
24
- }
24
+ /**
25
+ * Directory the built frontend is copied to, both inside the unit and beside
26
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
27
+ * so the two have to agree.
28
+ */
29
+ export const STANDALONE_FRONTEND_DIR = 'frontend'
25
30
 
26
- type DeploymentHandler =
27
- | {
28
- type: 'fetch'
29
- routes: Array<{ method: string; route: string; pikkuFuncId: string }>
30
- }
31
- | { type: 'queue'; queueName: string }
32
- | { type: 'scheduled'; schedule: string; taskName: string }
31
+ /**
32
+ * Module the bun entry imports its embedded assets from. It stays out of the
33
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
34
+ * manifest is built on and is resolved by `bun build --compile` instead.
35
+ */
36
+ export const STANDALONE_FRONTEND_MANIFEST = './frontend-assets.gen.js'
33
37
 
34
- interface DeploymentUnit {
35
- name: string
36
- role: string
37
- functionIds: string[]
38
- services: Array<{ capability: string; sourceServiceName: string }>
39
- dependsOn: string[]
40
- handlers: DeploymentHandler[]
41
- tags: string[]
38
+ /**
39
+ * Lines every standalone entry ends with, whatever the runtime.
40
+ *
41
+ * The ready line is the handshake a parent process — `pikku dev --spawn`, or
42
+ * the desktop shell that runs this binary as a sidecar — blocks on. It carries
43
+ * `server.port` rather than the requested port because a shell passes `PORT=0`:
44
+ * picking a free port in the parent and handing it down races anything else
45
+ * that binds it in between, so the server binds first and reports back.
46
+ */
47
+ const sidecarHandshakeLines = (): string[] => [
48
+ ` watchParentProcess()`,
49
+ ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
50
+ ]
51
+
52
+ const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`
53
+
54
+ /**
55
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
56
+ * back to the Node platform pair, which is right for every ordinary host — the
57
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
58
+ * toolchain is present anyway.
59
+ */
60
+ const rustcHostOutput = async (): Promise<string | undefined> => {
61
+ try {
62
+ const { execFileSync } = await import('node:child_process')
63
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' })
64
+ } catch {
65
+ return undefined
66
+ }
42
67
  }
43
68
 
44
- interface EntryGenerationContext {
45
- unit: DeploymentUnit
46
- unitDir: string
47
- bootstrapPath: string
48
- configImport: string
49
- configVar: string
50
- servicesImport: string
51
- servicesVar: string
52
- singletonServicesImport: string
53
- servicesType: string
54
- mcpImport: string
55
- mcpServerOption: string
69
+ export interface StandaloneProviderAdapterOptions {
70
+ runtime?: StandaloneRuntime
71
+ /**
72
+ * Generate a desktop shell (Tauri) around the compiled binary. Requires the
73
+ * `bun` runtime — the shell ships the binary as a sidecar, and only that
74
+ * runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
75
+ * and so has no such requirement.
76
+ */
77
+ desktop?: boolean
78
+ /** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
79
+ projectDir?: string
80
+ /** Bundle identifier for the shell. Derived from the app name when absent. */
81
+ desktopIdentifier?: string
82
+ /**
83
+ * An already-deployed server for the shell to open, instead of bundling one.
84
+ * The window is a webview onto that origin and nothing else is shipped.
85
+ */
86
+ desktopUrl?: string
56
87
  }
57
88
 
58
- export class StandaloneProviderAdapter {
89
+ export class StandaloneProviderAdapter implements ProviderAdapter {
59
90
  readonly name = 'standalone'
60
91
  readonly deployDirName = 'standalone'
61
92
  readonly singleUnit = true
62
93
  readonly runtime: StandaloneRuntime
94
+ readonly desktop: boolean
95
+ readonly projectDir?: string
96
+ readonly desktopIdentifier?: string
97
+ readonly desktopUrl?: string
63
98
 
64
99
  constructor(options: StandaloneProviderAdapterOptions = {}) {
65
100
  this.runtime = options.runtime ?? 'node'
101
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl)
102
+ this.projectDir = options.projectDir
103
+ this.desktopIdentifier = options.desktopIdentifier
104
+ this.desktopUrl = options.desktopUrl
66
105
  }
67
106
 
68
107
  generateEntrySource(ctx: EntryGenerationContext): string {
@@ -77,12 +116,19 @@ export class StandaloneProviderAdapter {
77
116
  `// Generated standalone entry — all functions in one process`,
78
117
  `import { LocalEventHubService } from '@pikku/core/channel/local'`,
79
118
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
80
- `import { pikkuState } from '@pikku/core/ecosystem'`,
81
- `import { wireAgentScorerQueueWorkers } from '@pikku/core/ecosystem/agent-scorer'`,
119
+ `import { pikkuState } from '@pikku/core/state'`,
120
+ `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
82
121
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
83
122
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
84
123
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
85
124
  `import { WebSocketServer } from 'ws'`,
125
+ SIDECAR_RUNTIME_IMPORT,
126
+ ...(ctx.frontend
127
+ ? [
128
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
129
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
130
+ ]
131
+ : []),
86
132
  ``,
87
133
  ctx.configImport,
88
134
  ctx.servicesImport,
@@ -114,9 +160,21 @@ export class StandaloneProviderAdapter {
114
160
  ` })`,
115
161
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
116
162
  ``,
163
+ ...(ctx.frontend
164
+ ? [
165
+ // Resolved from the running bundle rather than baked in at build
166
+ // time, so the distributable stays movable.
167
+ ` const staticMounts = [{`,
168
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
169
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
170
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
171
+ ` }]`,
172
+ ``,
173
+ ]
174
+ : []),
117
175
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
118
176
  ` const server = new PikkuNodeHTTPServer(`,
119
- ` { ...config, port, hostname },`,
177
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
120
178
  ` logger,`,
121
179
  ` {`,
122
180
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -129,6 +187,7 @@ export class StandaloneProviderAdapter {
129
187
  ` await triggerService.start()`,
130
188
  ` server.enableExitOnSignals()`,
131
189
  ` await server.start()`,
190
+ ...sidecarHandshakeLines(),
132
191
  `}`,
133
192
  ``,
134
193
  `main().catch((err) => {`,
@@ -143,10 +202,14 @@ export class StandaloneProviderAdapter {
143
202
  return [
144
203
  `// Generated standalone entry (bun runtime) — all functions in one process`,
145
204
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
146
- `import { pikkuState } from '@pikku/core/ecosystem'`,
147
- `import { wireAgentScorerQueueWorkers } from '@pikku/core/ecosystem/agent-scorer'`,
205
+ SIDECAR_RUNTIME_IMPORT,
206
+ `import { pikkuState } from '@pikku/core/state'`,
207
+ `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
148
208
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
149
209
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
210
+ ...(ctx.frontend
211
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
212
+ : []),
150
213
  ``,
151
214
  ctx.configImport,
152
215
  ctx.servicesImport,
@@ -178,12 +241,26 @@ export class StandaloneProviderAdapter {
178
241
  ` })`,
179
242
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
180
243
  ``,
181
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
244
+ ...(ctx.frontend
245
+ ? [
246
+ // A compiled binary has no directory to read: every file was
247
+ // embedded, and the map is the only way back to it.
248
+ ` const staticMounts = [{`,
249
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
250
+ ` directory: '',`,
251
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
252
+ ` assets: frontendAssets,`,
253
+ ` }]`,
254
+ ``,
255
+ ]
256
+ : []),
257
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
182
258
  ` await server.init()`,
183
259
  ` await schedulerService.start()`,
184
260
  ` await triggerService.start()`,
185
261
  ` server.enableExitOnSignals()`,
186
262
  ` await server.start()`,
263
+ ...sidecarHandshakeLines(),
187
264
  `}`,
188
265
  ``,
189
266
  `main().catch((err) => {`,
@@ -207,33 +284,12 @@ export class StandaloneProviderAdapter {
207
284
  }
208
285
 
209
286
  getExternals(): string[] {
210
- const externals = [
211
- 'node:*',
212
- 'child_process',
213
- 'crypto',
214
- 'fs',
215
- 'http',
216
- 'https',
217
- 'net',
218
- 'os',
219
- 'path',
220
- 'stream',
221
- 'url',
222
- 'util',
223
- 'zlib',
224
- 'events',
225
- 'buffer',
226
- 'querystring',
227
- 'tls',
228
- 'dns',
229
- 'dgram',
230
- 'cluster',
231
- 'worker_threads',
232
- ]
287
+ const externals = nodeBuiltinExternals()
233
288
  if (this.runtime === 'bun') {
234
289
  // Bun-native builtins are provided by the runtime and resolved by
235
290
  // `bun build --compile` — leave them as imports rather than inlining.
236
291
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi')
292
+ externals.push(STANDALONE_FRONTEND_MANIFEST)
237
293
  }
238
294
  return externals
239
295
  }
@@ -248,8 +304,37 @@ export class StandaloneProviderAdapter {
248
304
  onProgress?: (step: string, detail: string) => void
249
305
  }) {
250
306
  const { buildDir, logger } = options
307
+
308
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
309
+ // produce a shell should say so now, not after a bun compile.
310
+ if (this.desktop) {
311
+ if (!this.desktopUrl && this.runtime !== 'bun') {
312
+ return {
313
+ success: false,
314
+ errors: [
315
+ {
316
+ step: 'desktop',
317
+ 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}').`,
318
+ },
319
+ ],
320
+ }
321
+ }
322
+ if (!this.projectDir) {
323
+ return {
324
+ success: false,
325
+ errors: [
326
+ {
327
+ step: 'desktop',
328
+ error:
329
+ 'No project directory was supplied, so there is nowhere to write src-tauri/.',
330
+ },
331
+ ],
332
+ }
333
+ }
334
+ }
335
+
251
336
  const { join, dirname } = await import('node:path')
252
- const { readdir, writeFile, copyFile, mkdir } =
337
+ const { cp, readdir, writeFile, copyFile, mkdir } =
253
338
  await import('node:fs/promises')
254
339
  const { existsSync } = await import('node:fs')
255
340
 
@@ -282,6 +367,22 @@ export class StandaloneProviderAdapter {
282
367
  }
283
368
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`)
284
369
 
370
+ // --- 2a. Frontend, when the build produced one ---
371
+ // Both runtimes need it here rather than only in the build directory: node
372
+ // resolves the mount relative to the shipped bundle, and `bun build
373
+ // --compile` follows the manifest import out of the copy it is given.
374
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR)
375
+ if (existsSync(frontendDir)) {
376
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
377
+ recursive: true,
378
+ })
379
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '')
380
+ if (existsSync(join(unitDir, manifestName))) {
381
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName))
382
+ }
383
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`)
384
+ }
385
+
285
386
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
286
387
  if (this.runtime === 'bun') {
287
388
  const { execFileSync } = await import('node:child_process')
@@ -313,6 +414,58 @@ export class StandaloneProviderAdapter {
313
414
  }
314
415
  }
315
416
 
417
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
418
+ let targetTriple: string | undefined
419
+ if (this.desktop && this.projectDir) {
420
+ const { generateTauriShell, tauriBundleIdentifier } =
421
+ await import('./tauri/generate.js')
422
+ const { hostTargetTriple } = await import('./tauri/target-triple.js')
423
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js')
424
+ try {
425
+ const rustcVersionVerbose = await rustcHostOutput()
426
+ targetTriple = hostTargetTriple({ rustcVersionVerbose })
427
+ const shell = await generateTauriShell({
428
+ projectDir: this.projectDir,
429
+ appName,
430
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
431
+ targetTriple,
432
+ ...(this.desktopUrl
433
+ ? { remoteUrl: this.desktopUrl }
434
+ : { binaryPath: join(outDir, appName) }),
435
+ })
436
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`)
437
+ if (shell.written.length) {
438
+ logger.info(` wrote ${shell.written.join(', ')}`)
439
+ }
440
+ if (shell.preserved.length) {
441
+ logger.info(
442
+ ` kept your edits, not regenerated: ${shell.preserved.join(', ')}`
443
+ )
444
+ }
445
+ if (shell.sidecar) {
446
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`)
447
+ } else {
448
+ logger.info(` window opens: ${this.desktopUrl}`)
449
+ }
450
+ for (const line of renderTauriNextSteps({
451
+ shellDir: shell.dir,
452
+ hasRust: rustcVersionVerbose !== undefined,
453
+ })) {
454
+ logger.info(line)
455
+ }
456
+ } catch (e: unknown) {
457
+ return {
458
+ success: false,
459
+ errors: [
460
+ {
461
+ step: 'desktop',
462
+ error: e instanceof Error ? e.message : String(e),
463
+ },
464
+ ],
465
+ }
466
+ }
467
+ }
468
+
316
469
  // --- 3. config/ — empty template with .env example ---
317
470
  const configDir = join(outDir, 'config')
318
471
  await mkdir(configDir, { recursive: true })
@@ -341,9 +494,10 @@ export class StandaloneProviderAdapter {
341
494
 
342
495
  return {
343
496
  success: true,
344
- workersDeployed: [{ name: appName }],
497
+ workersDeployed: [appName],
345
498
  resourcesCreated: [],
346
499
  errors: [],
500
+ targetTriple,
347
501
  }
348
502
  }
349
503
  }
@@ -0,0 +1,167 @@
1
+ import { after, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
4
+ import { rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import { StandaloneProviderAdapter } from './adapter.js'
9
+ import { sidecarFileName } from './tauri/target-triple.js'
10
+
11
+ const tempDirs: string[] = []
12
+
13
+ after(() => {
14
+ for (const dir of tempDirs) {
15
+ rmSync(dir, { recursive: true, force: true })
16
+ }
17
+ })
18
+
19
+ const silentLogger = { info: () => {}, error: () => {} }
20
+
21
+ const builtUnit = async () => {
22
+ const buildDir = await mkdtemp(join(tmpdir(), 'pikku-desktop-deploy-'))
23
+ tempDirs.push(buildDir)
24
+ const unitDir = join(buildDir, 'shop')
25
+ await mkdir(unitDir, { recursive: true })
26
+ await writeFile(join(unitDir, 'bundle.js'), 'console.log("bundle")\n')
27
+
28
+ const projectDir = await mkdtemp(join(tmpdir(), 'pikku-desktop-project-'))
29
+ tempDirs.push(projectDir)
30
+ return { buildDir, projectDir, outDir: join(buildDir, 'shop-dist') }
31
+ }
32
+
33
+ describe('deploying a standalone unit as a desktop shell', () => {
34
+ test('refuses without the bun runtime, since there is no binary to ship', async () => {
35
+ const { buildDir, projectDir } = await builtUnit()
36
+
37
+ const result = await new StandaloneProviderAdapter({
38
+ runtime: 'node',
39
+ desktop: true,
40
+ projectDir,
41
+ }).deploy({ buildDir, logger: silentLogger })
42
+
43
+ assert.equal(result.success, false)
44
+ assert.match(result.errors[0]!.error, /bun/)
45
+ })
46
+
47
+ test('refuses when it was not told where the project lives', async () => {
48
+ const { buildDir } = await builtUnit()
49
+
50
+ const result = await new StandaloneProviderAdapter({
51
+ runtime: 'bun',
52
+ desktop: true,
53
+ }).deploy({ buildDir, logger: silentLogger })
54
+
55
+ assert.equal(result.success, false)
56
+ assert.match(result.errors[0]!.error, /project/i)
57
+ })
58
+
59
+ test('generates the shell around the compiled binary', async () => {
60
+ const { buildDir, projectDir, outDir } = await builtUnit()
61
+
62
+ const result = await new StandaloneProviderAdapter({
63
+ runtime: 'bun',
64
+ desktop: true,
65
+ projectDir,
66
+ }).deploy({ buildDir, logger: silentLogger })
67
+
68
+ assert.deepEqual(result.errors, [])
69
+ assert.equal(result.success, true)
70
+
71
+ const shellDir = join(projectDir, 'src-tauri')
72
+ const conf = JSON.parse(
73
+ await readFile(join(shellDir, 'tauri.conf.json'), 'utf-8')
74
+ )
75
+ assert.equal(conf.productName, 'shop')
76
+ assert.equal(conf.identifier, 'com.shop.desktop')
77
+ assert.deepEqual(conf.bundle.externalBin, ['binaries/shop'])
78
+
79
+ // The sidecar must be the binary the compile step actually produced, under
80
+ // the triple-suffixed name externalBin resolves.
81
+ const compiled = await readFile(join(outDir, 'shop'))
82
+ const shipped = await readFile(
83
+ join(shellDir, 'binaries', sidecarFileName('shop', result.targetTriple!))
84
+ )
85
+ assert.ok(compiled.equals(shipped))
86
+ assert.notEqual(
87
+ (
88
+ await stat(
89
+ join(
90
+ shellDir,
91
+ 'binaries',
92
+ sidecarFileName('shop', result.targetTriple!)
93
+ )
94
+ )
95
+ ).mode & 0o111,
96
+ 0
97
+ )
98
+ })
99
+
100
+ test('takes an explicit bundle identifier over the derived one', async () => {
101
+ const { buildDir, projectDir } = await builtUnit()
102
+
103
+ await new StandaloneProviderAdapter({
104
+ runtime: 'bun',
105
+ desktop: true,
106
+ projectDir,
107
+ desktopIdentifier: 'com.acme.pos',
108
+ }).deploy({ buildDir, logger: silentLogger })
109
+
110
+ const conf = JSON.parse(
111
+ await readFile(join(projectDir, 'src-tauri', 'tauri.conf.json'), 'utf-8')
112
+ )
113
+ assert.equal(conf.identifier, 'com.acme.pos')
114
+ })
115
+
116
+ test('points a remote shell at the url, with no runtime requirement', async () => {
117
+ const { buildDir, projectDir } = await builtUnit()
118
+
119
+ // Nothing is bundled, so there is no binary to compile and no reason to
120
+ // insist on bun — the node runtime has to be allowed through here.
121
+ const result = await new StandaloneProviderAdapter({
122
+ runtime: 'node',
123
+ desktop: true,
124
+ desktopUrl: 'https://shop.example.com',
125
+ projectDir,
126
+ }).deploy({ buildDir, logger: silentLogger })
127
+
128
+ assert.deepEqual(result.errors, [])
129
+ assert.equal(result.success, true)
130
+
131
+ const shellDir = join(projectDir, 'src-tauri')
132
+ const conf = JSON.parse(
133
+ await readFile(join(shellDir, 'tauri.conf.json'), 'utf-8')
134
+ )
135
+ assert.equal(conf.bundle.externalBin, undefined)
136
+ assert.equal(conf.app.windows[0].url, 'https://shop.example.com')
137
+ await assert.rejects(
138
+ () => stat(join(shellDir, 'binaries')),
139
+ 'a remote shell ships no sidecar'
140
+ )
141
+ })
142
+
143
+ test('refuses a url that is not http(s)', async () => {
144
+ const { buildDir, projectDir } = await builtUnit()
145
+
146
+ const result = await new StandaloneProviderAdapter({
147
+ runtime: 'node',
148
+ desktop: true,
149
+ desktopUrl: 'file:///etc/passwd',
150
+ projectDir,
151
+ }).deploy({ buildDir, logger: silentLogger })
152
+
153
+ assert.equal(result.success, false)
154
+ assert.match(result.errors[0]!.error, /http/i)
155
+ })
156
+
157
+ test('a plain standalone deploy generates no shell at all', async () => {
158
+ const { buildDir, projectDir } = await builtUnit()
159
+
160
+ await new StandaloneProviderAdapter({
161
+ runtime: 'bun',
162
+ projectDir,
163
+ }).deploy({ buildDir, logger: silentLogger })
164
+
165
+ await assert.rejects(() => stat(join(projectDir, 'src-tauri')))
166
+ })
167
+ })
package/src/index.ts CHANGED
@@ -16,9 +16,7 @@ import {
16
16
  } from './adapter.js'
17
17
 
18
18
  export { StandaloneProviderAdapter }
19
- export type {
20
- StandaloneProviderAdapterOptions,
21
- } from './adapter.js'
19
+ export type { StandaloneProviderAdapterOptions } from './adapter.js'
22
20
 
23
21
  export const createAdapter = (options?: StandaloneProviderAdapterOptions) =>
24
22
  new StandaloneProviderAdapter(options)
@@ -0,0 +1,13 @@
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 {
9
+ DATA_DIR_ENV,
10
+ PARENT_PID_ENV,
11
+ watchParentProcess,
12
+ } from './parent-watch.js'
13
+ export type { ParentWatch, ParentWatchOptions } from './parent-watch.js'
@@ -0,0 +1,112 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawn } from 'node:child_process'
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+
9
+ import { PARENT_PID_ENV } from './parent-watch.js'
10
+
11
+ const parentWatchUrl = pathToFileURL(
12
+ fileURLToPath(new URL('./parent-watch.ts', import.meta.url))
13
+ ).href
14
+
15
+ /**
16
+ * A stand-in for the compiled pikku binary: it installs the watch with its real
17
+ * defaults — including `process.exit` — and then holds the event loop open the
18
+ * way a listening server would.
19
+ */
20
+ const SIDECAR = `
21
+ import { watchParentProcess } from ${JSON.stringify(parentWatchUrl)}
22
+ watchParentProcess({ intervalMs: 25 })
23
+ setInterval(() => {}, 1000)
24
+ console.log('sidecar-up')
25
+ `
26
+
27
+ const spawnNode = (args: string[], env?: NodeJS.ProcessEnv) =>
28
+ spawn(process.execPath, args, {
29
+ env: { ...process.env, ...env },
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ })
32
+
33
+ const waitForExit = (child: ReturnType<typeof spawn>, timeoutMs: number) =>
34
+ new Promise<number | null>((resolve, reject) => {
35
+ const timer = setTimeout(
36
+ () => reject(new Error('the sidecar was still running')),
37
+ timeoutMs
38
+ )
39
+ child.once('exit', (code) => {
40
+ clearTimeout(timer)
41
+ resolve(code)
42
+ })
43
+ })
44
+
45
+ const waitForStdout = (child: ReturnType<typeof spawn>, needle: string) =>
46
+ new Promise<void>((resolve, reject) => {
47
+ let seen = ''
48
+ const timer = setTimeout(
49
+ () => reject(new Error(`never printed ${needle}: ${seen}`)),
50
+ 20_000
51
+ )
52
+ child.stdout?.on('data', (chunk) => {
53
+ seen += String(chunk)
54
+ if (seen.includes(needle)) {
55
+ clearTimeout(timer)
56
+ resolve()
57
+ }
58
+ })
59
+ child.stderr?.on('data', (chunk) => (seen += String(chunk)))
60
+ })
61
+
62
+ describe('a sidecar whose shell dies without cleaning up', () => {
63
+ it('exits on its own once the watched process is gone', async () => {
64
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-parent-watch-'))
65
+ const script = join(dir, 'sidecar.mjs')
66
+ await writeFile(script, SIDECAR, 'utf-8')
67
+
68
+ // Stands in for the desktop shell. Killed with SIGKILL, so nothing it
69
+ // might have done on the way out can be what stops the sidecar.
70
+ const shell = spawnNode(['-e', 'setInterval(() => {}, 1000)'])
71
+ const sidecar = spawnNode(['--import', 'tsx', script], {
72
+ [PARENT_PID_ENV]: String(shell.pid),
73
+ })
74
+
75
+ try {
76
+ await waitForStdout(sidecar, 'sidecar-up')
77
+ assert.equal(sidecar.exitCode, null, 'the sidecar must start out running')
78
+
79
+ shell.kill('SIGKILL')
80
+
81
+ const code = await waitForExit(sidecar, 20_000)
82
+ assert.equal(code, 0, 'an orphaned sidecar must exit cleanly')
83
+ } finally {
84
+ shell.kill('SIGKILL')
85
+ sidecar.kill('SIGKILL')
86
+ await rm(dir, { recursive: true, force: true })
87
+ }
88
+ })
89
+
90
+ it('keeps running when no shell pid was handed down', async () => {
91
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-parent-watch-'))
92
+ const script = join(dir, 'sidecar.mjs')
93
+ await writeFile(script, SIDECAR, 'utf-8')
94
+
95
+ const sidecar = spawnNode(['--import', 'tsx', script], {
96
+ [PARENT_PID_ENV]: undefined,
97
+ })
98
+
99
+ try {
100
+ await waitForStdout(sidecar, 'sidecar-up')
101
+ await new Promise((resolve) => setTimeout(resolve, 300))
102
+ assert.equal(
103
+ sidecar.exitCode,
104
+ null,
105
+ 'a server run from a terminal must not exit'
106
+ )
107
+ } finally {
108
+ sidecar.kill('SIGKILL')
109
+ await rm(dir, { recursive: true, force: true })
110
+ }
111
+ })
112
+ })