@statorjs/stator 1.0.0 → 1.1.0
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/package.json +2 -2
- package/src/client/bind.ts +3 -3
- package/src/client/index.ts +2 -2
- package/src/client/machine.ts +90 -22
- package/src/client/use.ts +20 -7
- package/src/compiler/virtual-code.ts +51 -6
- package/src/server/banner.ts +90 -0
- package/src/server/create-app.ts +5 -1
- package/src/server/dev.ts +6 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "1.
|
|
91
|
+
"@statorjs/stator": "1.1.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/client/bind.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { actorOf, type
|
|
1
|
+
import { actorOf, type ClientInstanceBase } from './use.ts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The one client binding mechanism: subscribe to a set of client actors, and on
|
|
@@ -12,7 +12,7 @@ import { actorOf, type ClientInstance } from './use.ts'
|
|
|
12
12
|
* Returns a disposer that unsubscribes.
|
|
13
13
|
*/
|
|
14
14
|
export function bind(
|
|
15
|
-
deps:
|
|
15
|
+
deps: ClientInstanceBase[],
|
|
16
16
|
compute: () => unknown,
|
|
17
17
|
apply: (value: unknown) => void,
|
|
18
18
|
): () => void {
|
|
@@ -38,7 +38,7 @@ export function bind(
|
|
|
38
38
|
* dependency changes (no diffing — `fn` owns its own DOM writes). The lower-
|
|
39
39
|
* level primitive `{key}Changed` desugars to. Returns a disposer.
|
|
40
40
|
*/
|
|
41
|
-
export function effect(deps:
|
|
41
|
+
export function effect(deps: ClientInstanceBase[], fn: () => void): () => void {
|
|
42
42
|
fn()
|
|
43
43
|
const unsubs = deps.map((d) => actorOf(d).subscribe(fn).unsubscribe)
|
|
44
44
|
return () => {
|
package/src/client/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export { bind, effect } from './bind.ts'
|
|
8
8
|
export { dispatch } from './dispatch.ts'
|
|
9
9
|
export { defineElement, StatorElement } from './element.ts'
|
|
10
|
-
export type {
|
|
10
|
+
export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
|
|
11
11
|
export { machine } from './machine.ts'
|
|
12
|
-
export type { ClientInstance } from './use.ts'
|
|
12
|
+
export type { ClientInstance, ClientInstanceBase } from './use.ts'
|
|
13
13
|
export { use } from './use.ts'
|
package/src/client/machine.ts
CHANGED
|
@@ -1,39 +1,107 @@
|
|
|
1
|
-
import { defineMachine, type
|
|
1
|
+
import { defineMachine, type MachineDef } from '../engine/index.ts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Terse machine form for component-local client state.
|
|
5
|
-
* `defineMachine` — context is every top-level key except the reserved `on` /
|
|
6
|
-
* `select` / `name`, transitions go under one implicit `active` state, and
|
|
7
|
-
* `select` becomes selectors.
|
|
4
|
+
* Terse machine form for component-local client state.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* const Qty = machine(
|
|
7
|
+
* { count: 1 }, // context: just data
|
|
8
|
+
* {
|
|
9
|
+
* on: { INC: (s) => { s.count += 1 } }, // s is typed from context
|
|
10
|
+
* select: { atMax: (s) => s.count >= 99 }, // exposed on the instance
|
|
11
|
+
* },
|
|
12
|
+
* )
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* Context and behavior are SEPARATE arguments so TypeScript can infer the
|
|
15
|
+
* context first and then contextually type every handler and selector
|
|
16
|
+
* against it — one bag can't be soundly inferred (the handlers' parameter
|
|
17
|
+
* types would depend on the same object they're part of; see the probe
|
|
18
|
+
* history in the 1.0 spec). Events are structurally loose (`ev.color` is
|
|
19
|
+
* `unknown`); the machine desugars to a single-state `defineMachine`.
|
|
20
|
+
*
|
|
21
|
+
* Client machines run only via `createActor` (never the Store), so the name
|
|
22
|
+
* is just a label and need not be unique.
|
|
13
23
|
*/
|
|
14
|
-
|
|
24
|
+
|
|
25
|
+
/** Client events are structurally loose — the terse form declares no union. */
|
|
26
|
+
export type ClientEvent = { type: string; [k: string]: unknown }
|
|
27
|
+
|
|
28
|
+
interface ClientTransitionObject<C> {
|
|
29
|
+
when?: (ctx: C, ev: ClientEvent) => boolean
|
|
30
|
+
do?: (ctx: C, ev: ClientEvent) => void
|
|
31
|
+
emit?: string | string[]
|
|
32
|
+
}
|
|
33
|
+
type ClientTransition<C> = ((ctx: C, ev: ClientEvent) => void) | ClientTransitionObject<C>
|
|
34
|
+
|
|
35
|
+
export interface ClientBehavior<C> {
|
|
15
36
|
/** Optional label (defaults to "ClientMachine"). */
|
|
16
37
|
name?: string
|
|
17
|
-
/** Transition map for the single implicit state. A bare function is an
|
|
18
|
-
* an object is a full `{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
/** Transition map for the single implicit state. A bare function is an
|
|
39
|
+
* action; an object is a full `{ when?, do?, emit? }` transition. */
|
|
40
|
+
on?: Record<string, ClientTransition<C>>
|
|
41
|
+
/** Derived values, exposed as live properties on the `use()` instance. */
|
|
42
|
+
select?: Record<string, (ctx: C) => unknown>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @deprecated One-bag form: context keys mixed with `on`/`select`. Kept for
|
|
46
|
+
* compatibility, but handlers see `any` — TypeScript cannot infer a
|
|
47
|
+
* context from the same object the handlers live in. Prefer
|
|
48
|
+
* `machine(context, behavior)`. */
|
|
49
|
+
export interface LegacyMachineConfig {
|
|
50
|
+
name?: string
|
|
51
|
+
// biome-ignore lint/suspicious/noExplicitAny: the one-bag form is untypeable by construction — that is exactly why the two-arg form exists
|
|
52
|
+
on?: Record<string, ((ctx: any, ev: ClientEvent) => void) | ClientTransitionObject<any>>
|
|
53
|
+
// biome-ignore lint/suspicious/noExplicitAny: same
|
|
23
54
|
select?: Record<string, (ctx: any) => unknown>
|
|
24
|
-
/** Everything else is initial context. */
|
|
25
55
|
[key: string]: unknown
|
|
26
56
|
}
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
const RESERVED = new Set(['name', 'on', 'select'])
|
|
59
|
+
|
|
60
|
+
// Data-only: no behavior, no selectors.
|
|
61
|
+
export function machine<C extends Record<string, unknown>>(
|
|
62
|
+
context: C & { on?: never; select?: never; name?: never },
|
|
63
|
+
): MachineDef<C, ClientEvent, 'active', Record<string, never>>
|
|
64
|
+
// Data + behavior: handlers and selectors typed against the context.
|
|
65
|
+
export function machine<
|
|
66
|
+
C extends Record<string, unknown>,
|
|
67
|
+
S extends Record<string, (ctx: C) => unknown>,
|
|
68
|
+
>(
|
|
69
|
+
context: C & { on?: never; select?: never; name?: never },
|
|
70
|
+
behavior: ClientBehavior<C> & { select?: S },
|
|
71
|
+
): MachineDef<C, ClientEvent, 'active', S>
|
|
72
|
+
/** @deprecated see LegacyMachineConfig */
|
|
73
|
+
export function machine(
|
|
74
|
+
config: LegacyMachineConfig,
|
|
75
|
+
// biome-ignore lint/suspicious/noExplicitAny: legacy view is deliberately loose
|
|
76
|
+
): MachineDef<Record<string, any>, ClientEvent, 'active', Record<string, (ctx: any) => any>>
|
|
77
|
+
export function machine(
|
|
78
|
+
first: Record<string, unknown>,
|
|
79
|
+
behavior?: ClientBehavior<never>,
|
|
80
|
+
): MachineDef {
|
|
81
|
+
let context: Record<string, unknown>
|
|
82
|
+
let name: string | undefined
|
|
83
|
+
let on: Record<string, unknown>
|
|
84
|
+
let select: Record<string, unknown>
|
|
85
|
+
if (behavior !== undefined || !Object.keys(first).some((k) => RESERVED.has(k))) {
|
|
86
|
+
context = first
|
|
87
|
+
name = behavior?.name
|
|
88
|
+
on = (behavior?.on ?? {}) as Record<string, unknown>
|
|
89
|
+
select = (behavior?.select ?? {}) as Record<string, unknown>
|
|
90
|
+
} else {
|
|
91
|
+
// Legacy one-bag: context is every non-reserved key.
|
|
92
|
+
const { name: n, on: o = {}, select: s = {}, ...rest } = first as LegacyMachineConfig
|
|
93
|
+
context = rest
|
|
94
|
+
name = n
|
|
95
|
+
on = o as Record<string, unknown>
|
|
96
|
+
select = s as Record<string, unknown>
|
|
97
|
+
}
|
|
30
98
|
return defineMachine({
|
|
31
99
|
name: name ?? 'ClientMachine',
|
|
32
100
|
lifecycle: 'session',
|
|
33
|
-
events: {} as
|
|
34
|
-
context
|
|
101
|
+
events: {} as ClientEvent,
|
|
102
|
+
context,
|
|
35
103
|
initial: 'active',
|
|
36
|
-
states: { active: { on } },
|
|
37
|
-
selectors: select,
|
|
104
|
+
states: { active: { on: on as never } },
|
|
105
|
+
selectors: select as never,
|
|
38
106
|
}) as MachineDef
|
|
39
107
|
}
|
package/src/client/use.ts
CHANGED
|
@@ -26,12 +26,23 @@ const CLIENT_HELPERS = {
|
|
|
26
26
|
* - `send(event)` to drive transitions.
|
|
27
27
|
* - the underlying actor (non-enumerable) for the binding loop to subscribe.
|
|
28
28
|
*/
|
|
29
|
-
export interface
|
|
29
|
+
export interface ClientInstanceBase {
|
|
30
30
|
send(event: { type: string; [k: string]: unknown } | string): void
|
|
31
31
|
/** @internal — the actor a binding subscribes to. */
|
|
32
32
|
readonly __actor: AnyActor
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Context keys + selector results as readonly live properties — the typed
|
|
36
|
+
* mirror of what the runtime proxy exposes. */
|
|
37
|
+
type ClientView<D> =
|
|
38
|
+
D extends MachineDef<infer C, infer _E, infer _S, infer Sel, infer _N>
|
|
39
|
+
? { readonly [K in keyof C]: C[K] } & {
|
|
40
|
+
readonly [K in keyof Sel]: Sel[K] extends (...args: never[]) => infer R ? R : never
|
|
41
|
+
}
|
|
42
|
+
: unknown
|
|
43
|
+
|
|
44
|
+
export type ClientInstance<D extends MachineDef = MachineDef> = ClientInstanceBase & ClientView<D>
|
|
45
|
+
|
|
35
46
|
/** An actor plus an optional deferred seed thunk — evaluated at the element's
|
|
36
47
|
* connect (when attributes are available), not at construction. */
|
|
37
48
|
export interface CollectedActor {
|
|
@@ -61,10 +72,12 @@ export function popCollector(): void {
|
|
|
61
72
|
* connect — required when the seed reads `this.attrs`, since attributes aren't
|
|
62
73
|
* available at construction (the custom-element upgrade-timing rule).
|
|
63
74
|
*/
|
|
64
|
-
export function use(
|
|
65
|
-
def:
|
|
66
|
-
seed?:
|
|
67
|
-
): ClientInstance {
|
|
75
|
+
export function use<D extends MachineDef>(
|
|
76
|
+
def: D,
|
|
77
|
+
seed?: Partial<D['__context']> | (() => Partial<D['__context']>),
|
|
78
|
+
): ClientInstance<D> {
|
|
79
|
+
// The live properties are defined dynamically below; the mapped view type
|
|
80
|
+
// is the static mirror of that runtime work.
|
|
68
81
|
const eager = typeof seed === 'object' ? seed : undefined
|
|
69
82
|
const snapshot: Snapshot<object> | undefined = eager
|
|
70
83
|
? {
|
|
@@ -110,10 +123,10 @@ export function use(
|
|
|
110
123
|
get: () => actor,
|
|
111
124
|
})
|
|
112
125
|
|
|
113
|
-
return inst as unknown as ClientInstance
|
|
126
|
+
return inst as unknown as ClientInstance<D>
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
/** Extract the actor from a `use()` instance (for the binding loop). */
|
|
117
|
-
export function actorOf(inst:
|
|
130
|
+
export function actorOf(inst: ClientInstanceBase): AnyActor {
|
|
118
131
|
return inst.__actor
|
|
119
132
|
}
|
|
@@ -43,10 +43,46 @@ export interface VirtualCodeResult {
|
|
|
43
43
|
styles: VirtualFile[]
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// Must mirror the RUNTIME's auto-injected globals exactly (compile.ts
|
|
47
|
+
// PRIMITIVES_IMPORT / client-emit.ts): a name injected here but not at
|
|
48
|
+
// runtime hides a missing-import bug; a name in both collides with the
|
|
49
|
+
// author's legitimate import (`raw` is NOT a runtime global — authors
|
|
50
|
+
// import it — which is why it must not be in this list).
|
|
51
|
+
const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'on', 'classList', 'styleList']
|
|
52
|
+
const CLIENT_GLOBALS = [
|
|
53
|
+
'StatorElement',
|
|
54
|
+
'use',
|
|
55
|
+
'machine',
|
|
56
|
+
'defineElement',
|
|
57
|
+
'bind',
|
|
58
|
+
'effect',
|
|
59
|
+
'dispatch',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** Local names the user's code already binds from `modulePath` imports —
|
|
63
|
+
* the runtime strips such habit-imports; the virtual emit (which must keep
|
|
64
|
+
* offsets faithful) instead injects only what the user DIDN'T import. */
|
|
65
|
+
function userImportedLocals(code: string, modulePath: string): Set<string> {
|
|
66
|
+
const locals = new Set<string>()
|
|
67
|
+
const re = new RegExp(
|
|
68
|
+
`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s+from\\s+['"]${modulePath.replace(/\//g, '\\/')}['"]`,
|
|
69
|
+
'g',
|
|
70
|
+
)
|
|
71
|
+
for (const m of code.matchAll(re)) {
|
|
72
|
+
for (const spec of m[1]!.split(',')) {
|
|
73
|
+
const parts = spec.trim().split(/\s+as\s+/)
|
|
74
|
+
const local = (parts[1] ?? parts[0] ?? '').trim().replace(/^type\s+/, '')
|
|
75
|
+
if (local) locals.add(local)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return locals
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function injectImports(globals: string[], modulePath: string, userCode: string): string {
|
|
82
|
+
const bound = userImportedLocals(userCode, modulePath)
|
|
83
|
+
const missing = globals.filter((g) => !bound.has(g))
|
|
84
|
+
return missing.length > 0 ? `import { ${missing.join(', ')} } from '${modulePath}';\n` : ''
|
|
85
|
+
}
|
|
50
86
|
// Aliased so they never collide with a component's own `InstanceOf` import.
|
|
51
87
|
// NOTE: `InstanceOf` comes from /template, not /machine — the template flavor
|
|
52
88
|
// includes `send`/`state`/`snapshot` (what a route-level binding actually is,
|
|
@@ -99,7 +135,11 @@ export function toVirtualCode(source: string): VirtualCodeResult {
|
|
|
99
135
|
* template expressions see the frontmatter's bindings. */
|
|
100
136
|
function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
101
137
|
const mappings: VirtualMapping[] = []
|
|
102
|
-
|
|
138
|
+
const userCode = (regions.frontmatter?.content ?? '') + regions.template.content
|
|
139
|
+
let code =
|
|
140
|
+
injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userCode) +
|
|
141
|
+
AMBIENT_TYPE_IMPORTS +
|
|
142
|
+
STATOR_AMBIENT
|
|
103
143
|
|
|
104
144
|
if (regions.frontmatter?.content.trim()) {
|
|
105
145
|
push(
|
|
@@ -145,7 +185,12 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
|
145
185
|
* resolution — `bind:text={theme.label}` → the class field — is a later phase.) */
|
|
146
186
|
function buildClientTsx(regions: ScannedRegions): VirtualFile {
|
|
147
187
|
const mappings: VirtualMapping[] = []
|
|
148
|
-
|
|
188
|
+
const userScript = regions.scripts.map((s) => s.content).join('\n')
|
|
189
|
+
let code =
|
|
190
|
+
injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userScript) +
|
|
191
|
+
injectImports(CLIENT_GLOBALS, '@statorjs/stator/client', userScript) +
|
|
192
|
+
AMBIENT_TYPE_IMPORTS +
|
|
193
|
+
STATOR_AMBIENT
|
|
149
194
|
|
|
150
195
|
for (const script of regions.scripts) {
|
|
151
196
|
push(mappings, script.contentOffset, code.length, script.content.length)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { networkInterfaces } from 'node:os'
|
|
3
|
+
import { logger } from './logger.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Human-facing startup/exit output for the DEV plane. Production keeps
|
|
7
|
+
* structured pino logs (ops parse those); the dev server prints for a
|
|
8
|
+
* person: clickable URLs, a one-line inventory, a graceful goodbye.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined
|
|
12
|
+
const c = {
|
|
13
|
+
bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
14
|
+
dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
15
|
+
cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
|
|
16
|
+
copper: (s: string) => (useColor ? `\x1b[38;5;173m${s}\x1b[0m` : s),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let cachedVersion: string | undefined
|
|
20
|
+
function statorVersion(): string {
|
|
21
|
+
if (!cachedVersion) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
24
|
+
cachedVersion = String(pkg.version)
|
|
25
|
+
} catch {
|
|
26
|
+
cachedVersion = ''
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return cachedVersion
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lanAddress(): string | undefined {
|
|
33
|
+
for (const nets of Object.values(networkInterfaces())) {
|
|
34
|
+
for (const net of nets ?? []) {
|
|
35
|
+
if (net.family === 'IPv4' && !net.internal) return net.address
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function printDevBanner(info: {
|
|
42
|
+
port: number
|
|
43
|
+
machines: number
|
|
44
|
+
routes: number
|
|
45
|
+
inspector?: boolean
|
|
46
|
+
}): void {
|
|
47
|
+
const v = statorVersion()
|
|
48
|
+
const lan = lanAddress()
|
|
49
|
+
const lines = [
|
|
50
|
+
'',
|
|
51
|
+
` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('dev server')}`,
|
|
52
|
+
'',
|
|
53
|
+
` ${c.dim('local')} ${c.cyan(`http://localhost:${info.port}/`)}`,
|
|
54
|
+
...(lan ? [` ${c.dim('network')} ${c.cyan(`http://${lan}:${info.port}/`)}`] : []),
|
|
55
|
+
'',
|
|
56
|
+
` ${c.dim(
|
|
57
|
+
`${info.machines} machine${info.machines === 1 ? '' : 's'} · ${info.routes} route${
|
|
58
|
+
info.routes === 1 ? '' : 's'
|
|
59
|
+
}${info.inspector ? ' · inspector on' : ''} — Ctrl+C to stop`,
|
|
60
|
+
)}`,
|
|
61
|
+
'',
|
|
62
|
+
]
|
|
63
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Exit a server process like a well-mannered CLI: first signal closes
|
|
68
|
+
* cleanly and exits 0 (Ctrl+C is a normal action, not a failure — without
|
|
69
|
+
* this, the process dies 130 and pnpm prints an ELIFECYCLE error banner);
|
|
70
|
+
* a second signal force-quits for anything that hangs in close().
|
|
71
|
+
*/
|
|
72
|
+
export function installGracefulShutdown(close: () => Promise<void> | void, quiet = false): void {
|
|
73
|
+
let closing = false
|
|
74
|
+
const handler = (signal: NodeJS.Signals) => {
|
|
75
|
+
if (closing) process.exit(130)
|
|
76
|
+
closing = true
|
|
77
|
+
if (!quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
|
|
78
|
+
void (async () => {
|
|
79
|
+
try {
|
|
80
|
+
await close()
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.warn({ err: String(err) }, 'error during shutdown')
|
|
83
|
+
}
|
|
84
|
+
process.exit(0)
|
|
85
|
+
})()
|
|
86
|
+
void signal
|
|
87
|
+
}
|
|
88
|
+
process.on('SIGINT', handler)
|
|
89
|
+
process.on('SIGTERM', handler)
|
|
90
|
+
}
|
package/src/server/create-app.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import { serve } from '@hono/node-server'
|
|
3
3
|
import type { AppStore } from './app-store.ts'
|
|
4
|
+
import { installGracefulShutdown } from './banner.ts'
|
|
4
5
|
import { discoverMachines } from './discovery.ts'
|
|
5
6
|
import { wireAppEffects } from './effects.ts'
|
|
6
7
|
import { buildHonoApp } from './http.ts'
|
|
@@ -71,10 +72,13 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
71
72
|
return {
|
|
72
73
|
listen(port: number): Promise<void> {
|
|
73
74
|
return new Promise((resolveFn) => {
|
|
74
|
-
serve({ fetch: app.fetch, port }, () => {
|
|
75
|
+
const server = serve({ fetch: app.fetch, port }, () => {
|
|
75
76
|
logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
|
|
76
77
|
resolveFn()
|
|
77
78
|
})
|
|
79
|
+
// Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
|
|
80
|
+
// prod: the structured logs are the record.
|
|
81
|
+
installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
|
|
78
82
|
})
|
|
79
83
|
},
|
|
80
84
|
fetch: (request: Request) => app.fetch(request),
|
package/src/server/dev.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createServer as createViteServer, type ViteDevServer } from 'vite'
|
|
|
7
7
|
import { compile } from '../compiler/index.ts'
|
|
8
8
|
import { machineStub, stator } from '../vite/index.ts'
|
|
9
9
|
import type { AppStore } from './app-store.ts'
|
|
10
|
+
import { installGracefulShutdown, printDevBanner } from './banner.ts'
|
|
10
11
|
import { logger } from './logger.ts'
|
|
11
12
|
import type { MachineStore } from './machine-store.ts'
|
|
12
13
|
import type { DiscoveredRoute } from './route-discovery.ts'
|
|
@@ -194,17 +195,13 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
194
195
|
const server = createHttpServer((req, res) => {
|
|
195
196
|
vite.middlewares(req, res, () => honoListener(req, res))
|
|
196
197
|
})
|
|
198
|
+
installGracefulShutdown(async () => {
|
|
199
|
+
await vite.close()
|
|
200
|
+
await new Promise<void>((done) => server.close(() => done()))
|
|
201
|
+
})
|
|
197
202
|
return new Promise((resolveFn) => {
|
|
198
203
|
server.listen(port, () => {
|
|
199
|
-
|
|
200
|
-
{
|
|
201
|
-
port,
|
|
202
|
-
mode: 'dev',
|
|
203
|
-
machines: machineCount,
|
|
204
|
-
routes: routes.length,
|
|
205
|
-
},
|
|
206
|
-
'listening',
|
|
207
|
-
)
|
|
204
|
+
printDevBanner({ port, machines: machineCount, routes: routes.length, inspector: true })
|
|
208
205
|
resolveFn()
|
|
209
206
|
})
|
|
210
207
|
})
|