@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.
- package/CHANGELOG.md +70 -0
- package/dist/adapter.d.ts +37 -45
- package/dist/adapter.js +187 -46
- package/dist/index.d.ts +1 -1
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +8 -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 +10 -5
- package/src/adapter.test.ts +186 -0
- package/src/adapter.ts +216 -62
- package/src/desktop-deploy.test.ts +167 -0
- package/src/index.ts +1 -3
- package/src/runtime/index.ts +13 -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
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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/
|
|
81
|
-
`import { wireAgentScorerQueueWorkers } from '@pikku/core/
|
|
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
|
-
|
|
147
|
-
`import {
|
|
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
|
-
|
|
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: [
|
|
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
|
+
})
|