@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
import { PARENT_PID_ENV, watchParentProcess } from './parent-watch.js'
|
|
6
|
+
|
|
7
|
+
const tick = () => delay(12)
|
|
8
|
+
|
|
9
|
+
describe('a sidecar that outlives the shell that spawned it', () => {
|
|
10
|
+
it('does not watch when no parent pid was handed down', async () => {
|
|
11
|
+
let orphaned = 0
|
|
12
|
+
const watch = watchParentProcess({
|
|
13
|
+
env: {},
|
|
14
|
+
onOrphaned: () => orphaned++,
|
|
15
|
+
intervalMs: 1,
|
|
16
|
+
})
|
|
17
|
+
try {
|
|
18
|
+
assert.equal(watch.watching, false)
|
|
19
|
+
await tick()
|
|
20
|
+
assert.equal(orphaned, 0)
|
|
21
|
+
} finally {
|
|
22
|
+
watch.stop()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('ignores a pid that is not a positive integer', async () => {
|
|
27
|
+
for (const value of ['', 'abc', '0', '-1', '1.5']) {
|
|
28
|
+
const watch = watchParentProcess({
|
|
29
|
+
env: { [PARENT_PID_ENV]: value },
|
|
30
|
+
onOrphaned: () => assert.fail(`pid ${value} should not be watched`),
|
|
31
|
+
intervalMs: 1,
|
|
32
|
+
})
|
|
33
|
+
assert.equal(watch.watching, false, `pid ${JSON.stringify(value)}`)
|
|
34
|
+
await tick()
|
|
35
|
+
watch.stop()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('stays quiet while the parent is alive', async () => {
|
|
40
|
+
let orphaned = 0
|
|
41
|
+
const watch = watchParentProcess({
|
|
42
|
+
env: { [PARENT_PID_ENV]: '4242' },
|
|
43
|
+
isAlive: () => true,
|
|
44
|
+
onOrphaned: () => orphaned++,
|
|
45
|
+
intervalMs: 1,
|
|
46
|
+
})
|
|
47
|
+
try {
|
|
48
|
+
assert.equal(watch.watching, true)
|
|
49
|
+
await tick()
|
|
50
|
+
assert.equal(orphaned, 0)
|
|
51
|
+
} finally {
|
|
52
|
+
watch.stop()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('fires once when the parent goes away, and stops polling', async () => {
|
|
57
|
+
let alive = true
|
|
58
|
+
let orphaned = 0
|
|
59
|
+
let probes = 0
|
|
60
|
+
const watch = watchParentProcess({
|
|
61
|
+
env: { [PARENT_PID_ENV]: '4242' },
|
|
62
|
+
isAlive: () => {
|
|
63
|
+
probes++
|
|
64
|
+
return alive
|
|
65
|
+
},
|
|
66
|
+
onOrphaned: () => orphaned++,
|
|
67
|
+
intervalMs: 1,
|
|
68
|
+
})
|
|
69
|
+
try {
|
|
70
|
+
await tick()
|
|
71
|
+
assert.equal(orphaned, 0)
|
|
72
|
+
alive = false
|
|
73
|
+
await tick()
|
|
74
|
+
assert.equal(orphaned, 1, 'the orphan handler must run')
|
|
75
|
+
const probesAtDeath = probes
|
|
76
|
+
await tick()
|
|
77
|
+
assert.equal(orphaned, 1, 'it must not fire again')
|
|
78
|
+
assert.equal(probes, probesAtDeath, 'polling must stop after it fires')
|
|
79
|
+
} finally {
|
|
80
|
+
watch.stop()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reads the pid the shell passes through the environment', () => {
|
|
85
|
+
const watch = watchParentProcess({
|
|
86
|
+
env: { [PARENT_PID_ENV]: String(process.pid) },
|
|
87
|
+
onOrphaned: () => {},
|
|
88
|
+
intervalMs: 60_000,
|
|
89
|
+
})
|
|
90
|
+
try {
|
|
91
|
+
assert.equal(watch.watching, true)
|
|
92
|
+
assert.equal(watch.parentPid, process.pid)
|
|
93
|
+
} finally {
|
|
94
|
+
watch.stop()
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('treats this very process as alive by default, and a reaped pid as gone', () => {
|
|
99
|
+
const watch = watchParentProcess({
|
|
100
|
+
env: { [PARENT_PID_ENV]: String(process.pid) },
|
|
101
|
+
onOrphaned: () => assert.fail('our own pid is alive'),
|
|
102
|
+
intervalMs: 60_000,
|
|
103
|
+
})
|
|
104
|
+
watch.stop()
|
|
105
|
+
|
|
106
|
+
// A pid that cannot exist: the default probe must report it gone rather
|
|
107
|
+
// than throwing, or an orphaned sidecar would never notice.
|
|
108
|
+
let orphaned = 0
|
|
109
|
+
const dead = watchParentProcess({
|
|
110
|
+
env: { [PARENT_PID_ENV]: '2147483646' },
|
|
111
|
+
onOrphaned: () => orphaned++,
|
|
112
|
+
intervalMs: 60_000,
|
|
113
|
+
})
|
|
114
|
+
dead.checkNow()
|
|
115
|
+
dead.stop()
|
|
116
|
+
assert.equal(orphaned, 1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('never keeps the process alive on its own', () => {
|
|
120
|
+
const watch = watchParentProcess({
|
|
121
|
+
env: { [PARENT_PID_ENV]: '4242' },
|
|
122
|
+
isAlive: () => true,
|
|
123
|
+
onOrphaned: () => {},
|
|
124
|
+
intervalMs: 60_000,
|
|
125
|
+
})
|
|
126
|
+
try {
|
|
127
|
+
assert.equal(
|
|
128
|
+
watch.holdsProcessOpen,
|
|
129
|
+
false,
|
|
130
|
+
'the poll timer must be unref-ed'
|
|
131
|
+
)
|
|
132
|
+
} finally {
|
|
133
|
+
watch.stop()
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('is safe to stop twice', () => {
|
|
138
|
+
const watch = watchParentProcess({
|
|
139
|
+
env: { [PARENT_PID_ENV]: '4242' },
|
|
140
|
+
isAlive: () => true,
|
|
141
|
+
onOrphaned: () => {},
|
|
142
|
+
intervalMs: 60_000,
|
|
143
|
+
})
|
|
144
|
+
watch.stop()
|
|
145
|
+
watch.stop()
|
|
146
|
+
assert.equal(watch.watching, false)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Environment variable a desktop shell uses to tell its sidecar where the
|
|
9
|
+
* SQLite file, uploaded content and runtime state belong. The shell resolves
|
|
10
|
+
* it from the platform's own app-data location, because a binary launched by
|
|
11
|
+
* double-click has no meaningful working directory.
|
|
12
|
+
*/
|
|
13
|
+
export const DATA_DIR_ENV = 'PIKKU_DATA_DIR'
|
|
14
|
+
|
|
15
|
+
export type ParentWatchOptions = {
|
|
16
|
+
/** Where the parent pid is read from. Defaults to `process.env`. */
|
|
17
|
+
env?: Record<string, string | undefined>
|
|
18
|
+
/** Probe for whether a pid is still running. Defaults to signal 0. */
|
|
19
|
+
isAlive?: (pid: number) => boolean
|
|
20
|
+
/** Run when the parent is found to be gone. Defaults to exiting cleanly. */
|
|
21
|
+
onOrphaned?: () => void
|
|
22
|
+
intervalMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ParentWatch = {
|
|
26
|
+
/** False when no usable parent pid was supplied — the watch is inert. */
|
|
27
|
+
readonly watching: boolean
|
|
28
|
+
readonly parentPid: number | undefined
|
|
29
|
+
/** True only if the poll timer would hold the event loop open. */
|
|
30
|
+
readonly holdsProcessOpen: boolean
|
|
31
|
+
/** Probe immediately rather than waiting for the next interval. */
|
|
32
|
+
checkNow(): void
|
|
33
|
+
stop(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A pid is alive if signalling it succeeds. `EPERM` also means alive — the
|
|
38
|
+
* process exists but belongs to another user — and only `ESRCH` means gone.
|
|
39
|
+
*/
|
|
40
|
+
const defaultIsAlive = (pid: number): boolean => {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0)
|
|
43
|
+
return true
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parsePid = (raw: string | undefined): number | undefined => {
|
|
50
|
+
if (!raw) return undefined
|
|
51
|
+
if (!/^\d+$/.test(raw)) return undefined
|
|
52
|
+
const pid = Number(raw)
|
|
53
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Exit when the process that spawned us does.
|
|
58
|
+
*
|
|
59
|
+
* Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
|
|
60
|
+
* runs that path. An orphaned pikku server keeps the SQLite file open, and the
|
|
61
|
+
* next launch — which single-instance only guards against a second *shell* —
|
|
62
|
+
* would be a second writer against the same database. Polling the parent is the only portable answer: neither
|
|
63
|
+
* `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
|
|
64
|
+
* reliable across the platforms a desktop build targets.
|
|
65
|
+
*
|
|
66
|
+
* With no parent pid in the environment the watch is inert, so a server run
|
|
67
|
+
* from a terminal or a container behaves exactly as it did before.
|
|
68
|
+
*/
|
|
69
|
+
export const watchParentProcess = (
|
|
70
|
+
options: ParentWatchOptions = {}
|
|
71
|
+
): ParentWatch => {
|
|
72
|
+
const env = options.env ?? process.env
|
|
73
|
+
const isAlive = options.isAlive ?? defaultIsAlive
|
|
74
|
+
const onOrphaned = options.onOrphaned ?? (() => process.exit(0))
|
|
75
|
+
const intervalMs = options.intervalMs ?? 1_000
|
|
76
|
+
|
|
77
|
+
const parentPid = parsePid(env[PARENT_PID_ENV])
|
|
78
|
+
|
|
79
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
80
|
+
let fired = false
|
|
81
|
+
|
|
82
|
+
const stop = () => {
|
|
83
|
+
if (timer) {
|
|
84
|
+
clearInterval(timer)
|
|
85
|
+
timer = undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const checkNow = () => {
|
|
90
|
+
if (parentPid === undefined || fired) return
|
|
91
|
+
if (isAlive(parentPid)) return
|
|
92
|
+
fired = true
|
|
93
|
+
stop()
|
|
94
|
+
onOrphaned()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (parentPid !== undefined) {
|
|
98
|
+
timer = setInterval(checkNow, intervalMs)
|
|
99
|
+
// The watch is a guard, not a reason to stay running: a server that has
|
|
100
|
+
// finished its work must still be allowed to exit.
|
|
101
|
+
timer.unref?.()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
get watching() {
|
|
106
|
+
return timer !== undefined
|
|
107
|
+
},
|
|
108
|
+
parentPid,
|
|
109
|
+
get holdsProcessOpen() {
|
|
110
|
+
return timer?.hasRef?.() ?? false
|
|
111
|
+
},
|
|
112
|
+
checkNow,
|
|
113
|
+
stop,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { SERVER_READY_MARKER } from '@pikku/deploy'
|
|
5
|
+
|
|
6
|
+
import { StandaloneProviderAdapter } from './adapter.js'
|
|
7
|
+
import { DATA_DIR_ENV, PARENT_PID_ENV } from './runtime/parent-watch.js'
|
|
8
|
+
|
|
9
|
+
const baseContext = {
|
|
10
|
+
unit: { name: 'app', role: 'function' },
|
|
11
|
+
unitDir: '/build/app',
|
|
12
|
+
bootstrapPath: './.pikku/pikku-bootstrap.gen.js',
|
|
13
|
+
configImport: `import { createConfig } from './config.js'`,
|
|
14
|
+
configVar: 'createConfig',
|
|
15
|
+
servicesImport: `import { createSingletonServices } from './services.js'`,
|
|
16
|
+
servicesVar: 'createSingletonServices',
|
|
17
|
+
singletonServicesImport: '',
|
|
18
|
+
servicesType: 'Record<string, unknown>',
|
|
19
|
+
mcpImport: '',
|
|
20
|
+
mcpServerOption: '',
|
|
21
|
+
} as never
|
|
22
|
+
|
|
23
|
+
const entries = (['node', 'bun'] as const).map((runtime) => ({
|
|
24
|
+
runtime,
|
|
25
|
+
source: new StandaloneProviderAdapter({ runtime }).generateEntrySource(
|
|
26
|
+
baseContext
|
|
27
|
+
),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
describe('the handshake a desktop shell reads off its sidecar', () => {
|
|
31
|
+
for (const { runtime, source } of entries) {
|
|
32
|
+
test(`the ${runtime} entry announces readiness with the shared marker`, () => {
|
|
33
|
+
assert.ok(
|
|
34
|
+
source.includes(SERVER_READY_MARKER),
|
|
35
|
+
'a shell waiting on `pikku: ready` would hang forever without it'
|
|
36
|
+
)
|
|
37
|
+
assert.match(
|
|
38
|
+
source,
|
|
39
|
+
/serverReadyLine|pikku: ready on http:\/\//,
|
|
40
|
+
'the line must carry a URL, not just the marker'
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test(`the ${runtime} entry reports the port the server bound, not the one requested`, () => {
|
|
45
|
+
assert.match(
|
|
46
|
+
source,
|
|
47
|
+
/server\.port/,
|
|
48
|
+
'PORT=0 is how the shell avoids a bind race, so the requested port is useless'
|
|
49
|
+
)
|
|
50
|
+
assert.doesNotMatch(
|
|
51
|
+
source,
|
|
52
|
+
/ready on http:\/\/\$\{hostname\}:\$\{port\}/,
|
|
53
|
+
'announcing the requested port re-introduces the race'
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test(`the ${runtime} entry announces readiness only after the server starts`, () => {
|
|
58
|
+
const readyAt = source.indexOf(SERVER_READY_MARKER)
|
|
59
|
+
const startAt = source.indexOf('await server.start()')
|
|
60
|
+
assert.ok(startAt > -1, 'the entry must start the server')
|
|
61
|
+
assert.ok(
|
|
62
|
+
readyAt > startAt,
|
|
63
|
+
'readiness printed before start() is a lie a parent will act on'
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test(`the ${runtime} entry installs the orphan guard`, () => {
|
|
68
|
+
assert.match(
|
|
69
|
+
source,
|
|
70
|
+
/watchParentProcess\(\)/,
|
|
71
|
+
'a hard crash of the shell would otherwise orphan this process'
|
|
72
|
+
)
|
|
73
|
+
assert.match(source, /@pikku\/deploy-standalone\/runtime/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test(`the ${runtime} entry still defaults to a fixed port outside a shell`, () => {
|
|
77
|
+
assert.match(
|
|
78
|
+
source,
|
|
79
|
+
/process\.env\.PORT \|\| '3000'/,
|
|
80
|
+
'an ordinary server deploy must keep its predictable port'
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('the environment contract is named in one place', () => {
|
|
86
|
+
assert.equal(PARENT_PID_ENV, 'PIKKU_PARENT_PID')
|
|
87
|
+
assert.equal(DATA_DIR_ENV, 'PIKKU_DATA_DIR')
|
|
88
|
+
})
|
|
89
|
+
})
|