@pikku/deploy-standalone 0.12.12 → 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 (46) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/adapter.d.ts +34 -0
  3. package/dist/adapter.js +184 -4
  4. package/dist/runtime/index.d.ts +9 -0
  5. package/dist/runtime/index.js +8 -0
  6. package/dist/runtime/parent-watch.d.ts +45 -0
  7. package/dist/runtime/parent-watch.js +87 -0
  8. package/dist/tauri/generate.d.ts +45 -0
  9. package/dist/tauri/generate.js +230 -0
  10. package/dist/tauri/icon.d.ts +1 -0
  11. package/dist/tauri/icon.js +54 -0
  12. package/dist/tauri/main-rs.d.ts +31 -0
  13. package/dist/tauri/main-rs.js +213 -0
  14. package/dist/tauri/next-steps.d.ts +15 -0
  15. package/dist/tauri/next-steps.js +16 -0
  16. package/dist/tauri/target-triple.d.ts +29 -0
  17. package/dist/tauri/target-triple.js +42 -0
  18. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  19. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  20. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  21. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  22. package/knowledge/decisions/index.md +19 -0
  23. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  24. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  25. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  26. package/knowledge/index.md +22 -0
  27. package/package.json +6 -4
  28. package/src/adapter.test.ts +186 -0
  29. package/src/adapter.ts +210 -4
  30. package/src/desktop-deploy.test.ts +167 -0
  31. package/src/runtime/index.ts +13 -0
  32. package/src/runtime/parent-watch.process.test.ts +112 -0
  33. package/src/runtime/parent-watch.test.ts +148 -0
  34. package/src/runtime/parent-watch.ts +115 -0
  35. package/src/sidecar-entry.test.ts +89 -0
  36. package/src/tauri/generate.test.ts +401 -0
  37. package/src/tauri/generate.ts +327 -0
  38. package/src/tauri/icon.test.ts +63 -0
  39. package/src/tauri/icon.ts +62 -0
  40. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  41. package/src/tauri/main-rs.ts +241 -0
  42. package/src/tauri/next-steps.test.ts +38 -0
  43. package/src/tauri/next-steps.ts +30 -0
  44. package/src/tauri/target-triple.test.ts +84 -0
  45. package/src/tauri/target-triple.ts +65 -0
  46. 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
+ })