@pikku/deploy-standalone 0.12.12 → 0.12.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +177 -0
- package/dist/adapter.d.ts +45 -0
- package/dist/adapter.js +448 -6
- package/dist/runtime/cli.d.ts +75 -0
- package/dist/runtime/cli.js +193 -0
- package/dist/runtime/index.d.ts +11 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/parent-watch.d.ts +45 -0
- package/dist/runtime/parent-watch.js +87 -0
- package/dist/tauri/generate.d.ts +45 -0
- package/dist/tauri/generate.js +230 -0
- package/dist/tauri/icon.d.ts +1 -0
- package/dist/tauri/icon.js +54 -0
- package/dist/tauri/main-rs.d.ts +31 -0
- package/dist/tauri/main-rs.js +213 -0
- package/dist/tauri/next-steps.d.ts +15 -0
- package/dist/tauri/next-steps.js +16 -0
- package/dist/tauri/target-triple.d.ts +29 -0
- package/dist/tauri/target-triple.js +42 -0
- package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
- package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
- package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
- package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
- package/knowledge/decisions/index.md +19 -0
- package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
- package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
- package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
- package/knowledge/index.md +22 -0
- package/package.json +7 -4
- package/src/adapter.test.ts +725 -0
- package/src/adapter.ts +508 -6
- package/src/desktop-deploy.test.ts +167 -0
- package/src/runtime/cli.test.ts +222 -0
- package/src/runtime/cli.ts +311 -0
- package/src/runtime/index.ts +31 -0
- package/src/runtime/parent-watch.process.test.ts +112 -0
- package/src/runtime/parent-watch.test.ts +148 -0
- package/src/runtime/parent-watch.ts +115 -0
- package/src/sidecar-entry.test.ts +89 -0
- package/src/tauri/generate.test.ts +401 -0
- package/src/tauri/generate.ts +327 -0
- package/src/tauri/icon.test.ts +63 -0
- package/src/tauri/icon.ts +62 -0
- package/src/tauri/main-rs.rustfmt.test.ts +86 -0
- package/src/tauri/main-rs.ts +241 -0
- package/src/tauri/next-steps.test.ts +38 -0
- package/src/tauri/next-steps.ts +30 -0
- package/src/tauri/target-triple.test.ts +84 -0
- package/src/tauri/target-triple.ts +65 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
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'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
parseStandaloneCommand,
|
|
17
|
+
runStandaloneCommand,
|
|
18
|
+
runDbCommand,
|
|
19
|
+
runBackupCommand,
|
|
20
|
+
resolveMigrationsDir,
|
|
21
|
+
MIGRATIONS_DIR_ENV,
|
|
22
|
+
} from './cli.js'
|
|
23
|
+
export type {
|
|
24
|
+
StandaloneCommand,
|
|
25
|
+
StandaloneDb,
|
|
26
|
+
StandaloneSqliteDb,
|
|
27
|
+
StandalonePostgresDb,
|
|
28
|
+
PostgresSql,
|
|
29
|
+
CommandOutput,
|
|
30
|
+
ParseOptions,
|
|
31
|
+
} from './cli.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
|
+
})
|
|
@@ -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
|
+
})
|