toiljs 0.0.62 → 0.0.64
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 +20 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +7 -1
- package/build/client/ssr/markers.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.d.ts +4 -1
- package/build/compiler/index.js +47 -18
- package/build/compiler/template-build.d.ts +3 -2
- package/build/compiler/template-build.js +16 -5
- package/build/compiler/toil-docs.generated.js +4 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/runtime/module.d.ts +3 -0
- package/build/devserver/runtime/module.js +5 -7
- package/docs/daemon.md +123 -0
- package/docs/index.md +5 -1
- package/docs/streams.md +147 -0
- package/docs/tiers.md +127 -0
- package/examples/basic/server/services/Stats.ts +2 -3
- package/examples/basic/server/services/remotes.ts +2 -2
- package/package.json +2 -2
- package/scripts/gen-toil-docs.mjs +3 -0
- package/src/client/index.ts +1 -0
- package/src/client/routing/hooks.ts +16 -3
- package/src/client/ssr/markers.tsx +4 -1
- package/src/compiler/index.ts +109 -53
- package/src/compiler/template-build.ts +38 -7
- package/src/compiler/toil-docs.generated.ts +4 -1
- package/src/devserver/runtime/module.ts +12 -14
- package/test/daemon-build.test.ts +31 -12
- package/test/devserver-database.test.ts +26 -0
- package/test/ssr-hydration.test.tsx +20 -5
- package/test/ssr-template.test.tsx +5 -3
- package/examples/basic/server/streams/Echo.ts +0 -49
package/docs/streams.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Streams
|
|
2
|
+
|
|
3
|
+
A `@stream` declares a long-lived, stateful protocol handler over WebTransport -
|
|
4
|
+
the **L2/L3** (regional / continental) stream tier of the Toil edge. Unlike a
|
|
5
|
+
`@rest` route, which is a fresh handler per request, a `@stream` is a **resident
|
|
6
|
+
WebAssembly box per connection**: it is created when the connection opens, lives
|
|
7
|
+
for the whole connection, and is torn down on close. State stored on its fields
|
|
8
|
+
**persists across events**, because it is the same box every time.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
@stream('echo')
|
|
12
|
+
class Echo {
|
|
13
|
+
private count: i32 = 0;
|
|
14
|
+
|
|
15
|
+
@connect
|
|
16
|
+
onConnect(): void {
|
|
17
|
+
this.count = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@message
|
|
21
|
+
onMessage(): void {
|
|
22
|
+
this.count = this.count + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@close
|
|
26
|
+
onClose(): void {}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Declaring a stream
|
|
31
|
+
|
|
32
|
+
`@stream(name)` marks a class as a stream handler and mounts it at the given
|
|
33
|
+
name/route. The class becomes a resident box; its fields are the connection's
|
|
34
|
+
state.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
@stream('echo') // mounted at /echo
|
|
38
|
+
class Echo { /* ... */ }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A stream lives on the **L2/L3 stream tier** and its default scope is **Regional
|
|
42
|
+
(L2)**. See [Tiers](./tiers.md) for the full tier model.
|
|
43
|
+
|
|
44
|
+
## Lifecycle hooks
|
|
45
|
+
|
|
46
|
+
A stream method is a lifecycle hook, chosen by its decorator. All hooks are
|
|
47
|
+
optional - declare only the ones you need; a missing hook is a no-op.
|
|
48
|
+
|
|
49
|
+
| Decorator | Fires when |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `@connect` | the connection opens (the box has just been created). |
|
|
52
|
+
| `@message` | an inbound frame arrives. |
|
|
53
|
+
| `@close` | the connection closes gracefully (the box is torn down after this hook). |
|
|
54
|
+
| `@disconnect` | the transport is lost abruptly. |
|
|
55
|
+
| `@channel` | an opt-in distributed channel delivers a message (advanced; see below). |
|
|
56
|
+
|
|
57
|
+
The `Echo` example above shows why state survives: `count` is set to `0` in
|
|
58
|
+
`@connect`, incremented on every `@message`, and the increments **accumulate**.
|
|
59
|
+
That is only possible because the same resident box handles every event for the
|
|
60
|
+
connection. A `@rest` handler's fields would reset on each request, since a
|
|
61
|
+
fresh handler is constructed per request.
|
|
62
|
+
|
|
63
|
+
`@channel` is an opt-in **distributed** channel (advanced) - a way for boxes to
|
|
64
|
+
exchange messages beyond a single connection. It is mentioned here for
|
|
65
|
+
completeness; most streams use only the four connection-lifecycle hooks.
|
|
66
|
+
|
|
67
|
+
## Placement
|
|
68
|
+
|
|
69
|
+
A `@stream` is distributed across the eligible L2/L3 stream nodes and pinned to
|
|
70
|
+
**ONE worker** for the connection's lifetime via QUIC connection-id steering. The
|
|
71
|
+
connection always lands on the same worker, so the box - and the state on its
|
|
72
|
+
fields - survives every event. You do not manage placement; the edge steers each
|
|
73
|
+
connection to its resident box automatically.
|
|
74
|
+
|
|
75
|
+
## The entry: `main.stream.ts`
|
|
76
|
+
|
|
77
|
+
The stream surface has its own entry, `server/main.stream.ts`, distinct from the
|
|
78
|
+
request entry (`server/main.ts`). It re-exports the WASM runtime exports and
|
|
79
|
+
imports the `@stream` classes, which pulls their compiler-generated
|
|
80
|
+
`stream_dispatch` export into the artifact.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
84
|
+
|
|
85
|
+
import './streams/Echo';
|
|
86
|
+
|
|
87
|
+
// Re-export the WASM entry points the host binds, exactly like main.ts.
|
|
88
|
+
export * from 'toiljs/server/runtime/exports';
|
|
89
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
90
|
+
revertOnError(message, fileName, line, column);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This entry compiles into its **own artifact**, `build/server/release-stream.wasm`
|
|
95
|
+
- the resident stream box - separate from the request build,
|
|
96
|
+
`build/server/release.wasm`. Add a stream as you grow by importing it here:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import './streams/Echo';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Build
|
|
103
|
+
|
|
104
|
+
`toiljs build` produces `release-stream.wasm` automatically when the project
|
|
105
|
+
declares a `@stream` surface. The single build runs one toilscript pass per tier,
|
|
106
|
+
handing each pass only the entries that belong to it, so `release.wasm` never
|
|
107
|
+
contains `stream_dispatch` and the stream artifact never contains the request
|
|
108
|
+
`handle`. Plain `@data` and helper modules are shared into every artifact.
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
$ toiljs build
|
|
112
|
+
$ ls build/server/*.wasm
|
|
113
|
+
build/server/release.wasm # L1 request (exports: handle)
|
|
114
|
+
build/server/release-stream.wasm # L2/L3 stream (exports: stream_dispatch)
|
|
115
|
+
build/server/release-cold.wasm # L4 daemon (exports: daemon_start, scheduled_tick)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
See [Tiers](./tiers.md) for how the three artifacts map to the deployment tiers.
|
|
119
|
+
|
|
120
|
+
## What runs today
|
|
121
|
+
|
|
122
|
+
The stream lifecycle hooks (`@connect` / `@message` / `@close` / `@disconnect`)
|
|
123
|
+
run **today**, and this proves a resident box keeps state across events - that is
|
|
124
|
+
exactly what the `Echo` example demonstrates by counting frames.
|
|
125
|
+
|
|
126
|
+
Reading the inbound frame **bytes** and replying is the **next increment**, not
|
|
127
|
+
yet available. That bridge is the `StreamPacket` / `StreamOutbound` API and the
|
|
128
|
+
typed `Server.STREAM.echo.connect()` client. The intended shape, once it lands:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
@message reply(packet: StreamPacket): StreamOutbound {
|
|
132
|
+
return StreamOutbound.reply(packet.bytes()); // echo the bytes back
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const stream = await Server.STREAM.echo.connect();
|
|
138
|
+
stream.send(new TextEncoder().encode('hello'));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Until then, the hooks run on the connection lifecycle and you observe state
|
|
142
|
+
through fields, as `Echo` does. See the comments in
|
|
143
|
+
`examples/streams/server/streams/Echo.ts` for the authoritative note.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
See also: [Tiers](./tiers.md), [Daemon](./daemon.md), [Routing](./routing.md).
|
package/docs/tiers.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Deployment tiers
|
|
2
|
+
|
|
3
|
+
A Toil app's server runs across several deployment **tiers** from one source
|
|
4
|
+
tree. Each tier has a different lifetime and placement on the edge, and compiles
|
|
5
|
+
into its own WebAssembly artifact. You write one project; `toiljs build` decides
|
|
6
|
+
which entries belong to which tier and emits one `.wasm` per tier. You opt into a
|
|
7
|
+
tier purely by adding its entry file and surface decorator; nothing else changes.
|
|
8
|
+
|
|
9
|
+
## The tiers
|
|
10
|
+
|
|
11
|
+
| Entry (`server/`) | Surface | Artifact | Tier | Lifetime / placement |
|
|
12
|
+
| --- | --- | --- | --- | --- |
|
|
13
|
+
| `main.ts` | `@rest` / `@service` / `@remote` | `build/server/release.wasm` | **L1** request | A fresh handler per request, anywhere on the edge. |
|
|
14
|
+
| `main.stream.ts` | `@stream` | `build/server/release-stream.wasm` | **L2/L3** stream | One resident box per connection, pinned to a worker via QUIC connection-id steering; its state survives every event. See [Streams](./streams.md). |
|
|
15
|
+
| `main.daemon.ts` | `@daemon` / `@scheduled` | `build/server/release-cold.wasm` | **L4** daemon | Exactly one leader-elected box per domain (warm standby, at-most-once failover) firing `@scheduled` tasks. See [Daemon](./daemon.md). |
|
|
16
|
+
|
|
17
|
+
The three tiers differ in how long a box lives and how many of it exist:
|
|
18
|
+
|
|
19
|
+
- **L1 request** is stateless. A `@rest` handler's fields reset each request,
|
|
20
|
+
because a fresh box serves each one, anywhere on the edge.
|
|
21
|
+
- **L2/L3 stream** is resident per connection. A `@stream` box is created when a
|
|
22
|
+
connection opens, lives for its lifetime, and is torn down on close, so its
|
|
23
|
+
fields persist across every event.
|
|
24
|
+
- **L4 daemon** is a single elected leader per domain - the global coordination
|
|
25
|
+
tier - running recurring background work on a cadence.
|
|
26
|
+
|
|
27
|
+
## How the build works
|
|
28
|
+
|
|
29
|
+
`toiljs build` runs one toilscript pass per tier, handing each pass only the
|
|
30
|
+
entries that belong to it. Tier membership is decided by the surface decorator or
|
|
31
|
+
by the entry naming convention:
|
|
32
|
+
|
|
33
|
+
- a runtime-export entry that is **not** `*.stream.ts` or `*.daemon.ts` is the
|
|
34
|
+
**request** entry (`main.ts`), which compiles `@rest` / `@service` / `@remote`;
|
|
35
|
+
- `*.stream.ts` is the **stream** entry, which compiles `@stream`;
|
|
36
|
+
- `*.daemon.ts` is the **daemon** entry, which compiles `@daemon` / `@scheduled`.
|
|
37
|
+
|
|
38
|
+
Plain `@data` and helper modules carry no tier of their own, so they are shared
|
|
39
|
+
into every artifact. Routing each entry to exactly one tier is what keeps
|
|
40
|
+
`release.wasm` free of `stream_dispatch` and keeps the daemon artifact free of
|
|
41
|
+
the request `handle`.
|
|
42
|
+
|
|
43
|
+
Each entry is a thin file that imports its tier's modules and re-exports the
|
|
44
|
+
right runtime hooks. The stream and request entries re-export the request runtime
|
|
45
|
+
exports; the daemon entry does not, because a cold artifact exposes
|
|
46
|
+
`daemon_start` / `scheduled_tick`, not `handle`:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// server/main.stream.ts - the L2/L3 stream entry
|
|
50
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
51
|
+
import './streams/Echo';
|
|
52
|
+
|
|
53
|
+
export * from 'toiljs/server/runtime/exports';
|
|
54
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
55
|
+
revertOnError(message, fileName, line, column);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// server/main.daemon.ts - the L4 daemon entry
|
|
61
|
+
import { revertOnError } from 'toiljs/server/runtime/abort/abort';
|
|
62
|
+
import './daemon/Jobs';
|
|
63
|
+
|
|
64
|
+
// NOTE: no `export *` from the request runtime - a cold artifact exposes
|
|
65
|
+
// daemon_start/scheduled_tick, not the request `handle`.
|
|
66
|
+
export function abort(message: string, fileName: string, line: u32, column: u32): void {
|
|
67
|
+
revertOnError(message, fileName, line, column);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A single build produces the artifacts side by side:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
$ ls build/server/*.wasm
|
|
75
|
+
build/server/release.wasm # L1 request (exports: handle)
|
|
76
|
+
build/server/release-stream.wasm # L2/L3 stream (exports: stream_dispatch)
|
|
77
|
+
build/server/release-cold.wasm # L4 daemon (exports: daemon_start, scheduled_tick)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Single-artifact default
|
|
81
|
+
|
|
82
|
+
A project with no `@stream` and no `@daemon` surface keeps the legacy
|
|
83
|
+
single-artifact build - just `build/server/release.wasm`. The stream and daemon
|
|
84
|
+
tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
|
|
85
|
+
`release-stream.wasm`, add `main.daemon.ts` (and a `@daemon` class) to get
|
|
86
|
+
`release-cold.wasm`. Existing request-only apps build exactly as before.
|
|
87
|
+
|
|
88
|
+
## When to use each tier
|
|
89
|
+
|
|
90
|
+
- **L1 request** for request/response and RPC: `@rest` controllers, `@service` /
|
|
91
|
+
`@remote` callable surface. The default tier; most code lives here.
|
|
92
|
+
- **L2/L3 stream** for stateful, long-lived connections where per-connection
|
|
93
|
+
state must survive across events - the resident box is pinned to one worker for
|
|
94
|
+
the connection's lifetime.
|
|
95
|
+
- **L4 daemon** for scheduled and coordination work: rollups, cleanup, polling an
|
|
96
|
+
upstream, anything that should run exactly once per domain on a cadence rather
|
|
97
|
+
than per request.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// server/streams/Echo.ts - L2/L3: the box is resident, so `count` persists.
|
|
101
|
+
@stream('echo')
|
|
102
|
+
class Echo {
|
|
103
|
+
private count: i32 = 0;
|
|
104
|
+
|
|
105
|
+
@connect onConnect(): void { this.count = 0; }
|
|
106
|
+
@message onMessage(): void { this.count = this.count + 1; }
|
|
107
|
+
@close onClose(): void { /* box torn down after this hook */ }
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// server/daemon/Jobs.ts - L4: one leader per domain runs this hourly.
|
|
113
|
+
@daemon
|
|
114
|
+
class Jobs {
|
|
115
|
+
@scheduled('1h')
|
|
116
|
+
hourly(): void {
|
|
117
|
+
// Recurring background work: rollups, cleanup, polling an upstream, ...
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## See also
|
|
123
|
+
|
|
124
|
+
- [Streams](./streams.md) - the `@stream` surface and the L2/L3 tier.
|
|
125
|
+
- [Daemon](./daemon.md) - the `@daemon` surface and the L4 tier.
|
|
126
|
+
- [Routing](./routing.md) - `@rest` controllers on the L1 request tier.
|
|
127
|
+
- [RPC](./rpc.md) - `@service` / `@remote` and the generated client.
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { store } from '../core/store';
|
|
2
2
|
|
|
3
3
|
/** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
|
|
4
|
-
|
|
4
|
+
/*@service
|
|
5
5
|
class Stats {
|
|
6
|
-
/** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
|
|
7
6
|
@remote
|
|
8
7
|
public playerCount(): i32 {
|
|
9
8
|
return store.size;
|
|
10
9
|
}
|
|
11
|
-
}
|
|
10
|
+
}*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.64",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"nodemailer": "^9.0.1",
|
|
135
135
|
"picocolors": "^1.1.1",
|
|
136
136
|
"sharp": "^0.35.2",
|
|
137
|
-
"toilscript": "^0.1.
|
|
137
|
+
"toilscript": "^0.1.41",
|
|
138
138
|
"typescript-eslint": "^8.62.0",
|
|
139
139
|
"vite": "^8.1.0",
|
|
140
140
|
"vite-imagetools": "^10.0.1",
|
package/src/client/index.ts
CHANGED
|
@@ -101,12 +101,25 @@ function useLocationSubscription(): void {
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** Build-only override for the SSR pathname, set by the template extractor per route via
|
|
105
|
+
* {@link __setSsrLocation}. Lets location-dependent markup (a `NavLink`'s active class /
|
|
106
|
+
* `aria-current`) render as the route's own URL so it matches what the client computes on
|
|
107
|
+
* hydration, instead of the `/` default. Ignored in the browser (the live URL wins). */
|
|
108
|
+
let ssrLocationOverride: string | null = null;
|
|
109
|
+
|
|
110
|
+
/** Build-only: set the pathname the extractor is currently rendering (or `null` to clear).
|
|
111
|
+
* No effect in the browser. Exported through `toiljs/client` for the compiler. */
|
|
112
|
+
export function __setSsrLocation(path: string | null): void {
|
|
113
|
+
ssrLocationOverride = path;
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
/** Subscribes to and returns the current `location.pathname`. SSR-safe: during a
|
|
105
|
-
* server render
|
|
106
|
-
*
|
|
117
|
+
* server render there is no `window`, so it reports the extractor's override (the route
|
|
118
|
+
* being rendered) or `/`; the client recomputes on hydration. */
|
|
107
119
|
export function useLocation(): string {
|
|
108
120
|
useLocationSubscription();
|
|
109
|
-
|
|
121
|
+
if (typeof window === 'undefined') return ssrLocationOverride ?? '/';
|
|
122
|
+
return window.location.pathname;
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
/** Alias of {@link useLocation}: the current `location.pathname`. */
|
|
@@ -145,7 +145,10 @@ export function Repeat<T>(props: RepeatProps<T>): ReactNode {
|
|
|
145
145
|
return createElement(
|
|
146
146
|
Fragment,
|
|
147
147
|
null,
|
|
148
|
-
|
|
148
|
+
// Each row is wrapped in a keyed Fragment so React has a stable list key (the
|
|
149
|
+
// row markup itself need not carry one). Index keys are fine here: an SSR'd
|
|
150
|
+
// region hydrates 1:1 against the host's pre-stamped rows and does not reorder.
|
|
151
|
+
props.each.map((item, i) => createElement(Fragment, { key: i }, props.children(item, i))),
|
|
149
152
|
);
|
|
150
153
|
}
|
|
151
154
|
|
package/src/compiler/index.ts
CHANGED
|
@@ -137,30 +137,42 @@ export async function buildServer(root: string): Promise<void> {
|
|
|
137
137
|
// (optimization, features, runtime) still come from the toilconfig's `release` target.
|
|
138
138
|
const files = serverEntryFiles(root);
|
|
139
139
|
|
|
140
|
-
// A project that declares a `@daemon` (cold surface)
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
140
|
+
// A project that declares a `@daemon` (L4 cold surface) and/or a `@stream` (L2/L3 stream
|
|
141
|
+
// surface) compiles the ONE source tree into SEPARATE artifacts, one per deployment tier, via
|
|
142
|
+
// one toilscript pass each; a project with only the legacy request surface keeps the
|
|
143
|
+
// single-artifact path (byte-identical to before). The three tiers:
|
|
144
|
+
// - REQUEST (L1) `server/main.ts` + `@rest`/`@service`/`@remote` -> `release.wasm`
|
|
145
|
+
// - STREAM (L2/L3) `server/main.stream.ts` + `@stream` -> `release-stream.wasm`
|
|
146
|
+
// - DAEMON (L4) `@daemon`/`@scheduled` -> `release-cold.wasm`
|
|
147
|
+
// toilscript's gating matrix HARD-ERRORS a class compiled under the wrong --targetMode, so each
|
|
148
|
+
// pass is handed only the files eligible for its tier (`@data`/`@database`/plain helpers are
|
|
149
|
+
// SHARED into every pass). The request pass runs LAST because it (re)writes shared/server.ts via
|
|
150
|
+
// --rpcModule, which the downstream client build imports.
|
|
145
151
|
const split = splitSurfaceFiles(root, files);
|
|
146
|
-
if (split.hasDaemon) {
|
|
152
|
+
if (split.hasDaemon || split.hasStream) {
|
|
147
153
|
const artifacts = serverArtifacts(root);
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
154
|
+
// DAEMON (cold) pass: --targetMode cold, no client RPC surface.
|
|
155
|
+
if (split.hasDaemon)
|
|
156
|
+
await runToilscriptPass(root, binJs, split.cold, {
|
|
157
|
+
mode: 'cold',
|
|
158
|
+
outFile: artifacts.cold,
|
|
159
|
+
withRpc: false,
|
|
160
|
+
});
|
|
161
|
+
// STREAM pass: --targetMode hot into its OWN `release-stream.wasm`, no client RPC surface
|
|
162
|
+
// (a resident stream box exposes `stream_dispatch`, not the request client surface). Driven
|
|
163
|
+
// by `server/main.stream.ts` + the `@stream` classes; the request box never loads it.
|
|
164
|
+
if (split.hasStream && split.stream.length > 0)
|
|
165
|
+
await runToilscriptPass(root, binJs, split.stream, {
|
|
166
|
+
mode: 'hot',
|
|
167
|
+
outFile: artifacts.stream,
|
|
168
|
+
withRpc: false,
|
|
169
|
+
});
|
|
170
|
+
// REQUEST pass: the L1 artifact (= the legacy `outFile`, AN-1), WITH the client RPC surface.
|
|
171
|
+
// A pure daemon/stream project (no request files) skips it so toilscript is not handed an
|
|
172
|
+
// empty entry set; the request path then stays idle (no `handle` export), correct for a
|
|
173
|
+
// background-only worker.
|
|
174
|
+
if (split.request.length > 0)
|
|
175
|
+
await runToilscriptPass(root, binJs, split.request, {
|
|
164
176
|
mode: 'hot',
|
|
165
177
|
outFile: serverWasmFile(root),
|
|
166
178
|
withRpc: true,
|
|
@@ -168,7 +180,7 @@ export async function buildServer(root: string): Promise<void> {
|
|
|
168
180
|
return;
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
// Legacy single-artifact path (no daemon surface): exactly today's invocation.
|
|
183
|
+
// Legacy single-artifact path (no daemon/stream surface): exactly today's invocation.
|
|
172
184
|
await runToilscriptPass(root, binJs, files, { mode: null, outFile: null, withRpc: true });
|
|
173
185
|
}
|
|
174
186
|
|
|
@@ -191,54 +203,85 @@ function resolveToilscriptBin(root: string): string {
|
|
|
191
203
|
}
|
|
192
204
|
}
|
|
193
205
|
|
|
194
|
-
/** Files classified per
|
|
206
|
+
/** Files classified per deployment TIER for the multi-artifact build. */
|
|
195
207
|
interface SurfaceSplit {
|
|
196
|
-
/** Whether any file declares a `@daemon` (so a cold pass is needed at all). */
|
|
208
|
+
/** Whether any file declares a `@daemon` (so a cold/daemon pass is needed at all). */
|
|
197
209
|
readonly hasDaemon: boolean;
|
|
198
|
-
/**
|
|
210
|
+
/** Whether any file declares a `@stream` (or is a `*.stream.ts` entry), so a stream pass is needed. */
|
|
211
|
+
readonly hasStream: boolean;
|
|
212
|
+
/** Files for the DAEMON (cold) pass: `@daemon`/`@scheduled` surfaces + shared helpers. */
|
|
199
213
|
readonly cold: string[];
|
|
200
|
-
/** Files
|
|
201
|
-
readonly
|
|
214
|
+
/** Files for the STREAM pass: `@stream` surfaces + the `*.stream.ts` entry + shared helpers. */
|
|
215
|
+
readonly stream: string[];
|
|
216
|
+
/** Files for the REQUEST pass: `@rest`/`@service`/`@remote` surfaces + the request entry + shared helpers. */
|
|
217
|
+
readonly request: string[];
|
|
202
218
|
}
|
|
203
219
|
|
|
204
|
-
/** A `@daemon`/`@scheduled` decorator at line start (
|
|
220
|
+
/** A `@daemon`/`@scheduled` decorator at line start (the L4 cold/daemon surface). */
|
|
205
221
|
const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
|
|
206
|
-
/** A
|
|
207
|
-
const
|
|
222
|
+
/** A `@stream` decorator at line start (the L2/L3 stream surface). */
|
|
223
|
+
const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
|
|
224
|
+
/** A request-surface decorator at line start (`@rest`/`@route`/`@service`/`@remote`, the L1 tier). */
|
|
225
|
+
const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
|
|
226
|
+
/** A server ENTRY re-exports the runtime WASM entry points; this marks `main.ts` / `main.stream.ts`
|
|
227
|
+
* (vs a plain `@data`/helper), so each entry is routed to exactly ONE tier and two entries never
|
|
228
|
+
* collide on a duplicate `export *` in the same pass. */
|
|
229
|
+
const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
|
|
230
|
+
|
|
231
|
+
/** True for a STREAM-tier entry by the `*.stream.ts` naming convention (e.g. `main.stream.ts`). */
|
|
232
|
+
function isStreamEntryFile(rel: string): boolean {
|
|
233
|
+
return rel.endsWith('.stream.ts');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** True for a COLD/daemon-tier entry by the `*.daemon.ts` naming convention (e.g. `main.daemon.ts`). */
|
|
237
|
+
function isDaemonEntryFile(rel: string): boolean {
|
|
238
|
+
return rel.endsWith('.daemon.ts');
|
|
239
|
+
}
|
|
208
240
|
|
|
209
241
|
/**
|
|
210
|
-
* Classify each server source file by
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
242
|
+
* Classify each server source file by its deployment TIER, so each toilscript pass is handed only
|
|
243
|
+
* the files valid for its `--targetMode` (toilscript HARD-ERRORS a class compiled under the wrong
|
|
244
|
+
* mode). Three tiers:
|
|
245
|
+
* - COLD/daemon: a file declaring `@daemon`/`@scheduled` -> `release-cold.wasm`.
|
|
246
|
+
* - STREAM (L2/L3): a file declaring `@stream`, OR a `*.stream.ts` entry (`main.stream.ts`) ->
|
|
247
|
+
* `release-stream.wasm`.
|
|
248
|
+
* - REQUEST (L1): a file declaring `@rest`/`@service`/`@remote`, OR a non-`*.stream.ts` runtime
|
|
249
|
+
* ENTRY (`main.ts`) -> `release.wasm`.
|
|
250
|
+
* A file with NONE of these (a plain `@data`/`@database`/helper) is SHARED into every pass, matching
|
|
251
|
+
* toilscript's class-level gating. Routing each entry to exactly one tier keeps `release.wasm` free
|
|
252
|
+
* of `stream_dispatch` and stops two entries re-exporting the runtime in the same pass.
|
|
217
253
|
*/
|
|
218
254
|
export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
|
|
219
255
|
let hasDaemon = false;
|
|
256
|
+
let hasStream = false;
|
|
220
257
|
const cold: string[] = [];
|
|
221
|
-
const
|
|
258
|
+
const stream: string[] = [];
|
|
259
|
+
const request: string[] = [];
|
|
222
260
|
for (const rel of files) {
|
|
223
261
|
let src = '';
|
|
224
262
|
try {
|
|
225
263
|
src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
226
264
|
} catch {
|
|
227
|
-
// unreadable: keep it in
|
|
265
|
+
// unreadable: keep it in EVERY pass (let toilscript surface the error).
|
|
228
266
|
cold.push(rel);
|
|
229
|
-
|
|
267
|
+
stream.push(rel);
|
|
268
|
+
request.push(rel);
|
|
230
269
|
continue;
|
|
231
270
|
}
|
|
232
|
-
const isCold = COLD_DECORATOR.test(src);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
271
|
+
const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
|
|
272
|
+
const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
|
|
273
|
+
const isRequest =
|
|
274
|
+
REQUEST_DECORATOR.test(src) ||
|
|
275
|
+
(RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
|
|
276
|
+
if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
|
|
277
|
+
if (isStream) hasStream = true;
|
|
278
|
+
// A file with no tier-specific surface is a SHARED helper, compiled into every pass.
|
|
279
|
+
const shared = !isCold && !isStream && !isRequest;
|
|
280
|
+
if (isCold || shared) cold.push(rel);
|
|
281
|
+
if (isStream || shared) stream.push(rel);
|
|
282
|
+
if (isRequest || shared) request.push(rel);
|
|
240
283
|
}
|
|
241
|
-
return { hasDaemon, cold,
|
|
284
|
+
return { hasDaemon, hasStream, cold, stream, request };
|
|
242
285
|
}
|
|
243
286
|
|
|
244
287
|
interface PassOptions {
|
|
@@ -264,6 +307,11 @@ function runToilscriptPass(
|
|
|
264
307
|
if (opts.mode !== null) args.push('--targetMode', opts.mode);
|
|
265
308
|
if (opts.outFile !== null) args.push('--outFile', opts.outFile);
|
|
266
309
|
if (opts.withRpc) args.push('--rpcModule', 'shared/server.ts');
|
|
310
|
+
// Each pass is handed its OWN entry subset (the per-tier `files`); suppress the toilconfig
|
|
311
|
+
// `entries` so toilscript does not ALSO append every project entry to every pass (which would
|
|
312
|
+
// pull, e.g., a `@stream` class into the cold daemon pass). serverEntryFiles already folds
|
|
313
|
+
// config.entries into `files`, so no entry is lost by ignoring them here.
|
|
314
|
+
args.push('--noConfigEntries');
|
|
267
315
|
args.push('--disableWarning', '235');
|
|
268
316
|
|
|
269
317
|
return new Promise<void>((resolve, reject) => {
|
|
@@ -417,32 +465,40 @@ function serverWasmFile(root: string): string {
|
|
|
417
465
|
* present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
|
|
418
466
|
* mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
|
|
419
467
|
export interface ServerArtifacts {
|
|
420
|
-
/** Absolute path to the hot (request
|
|
468
|
+
/** Absolute path to the hot (request) artifact. */
|
|
421
469
|
readonly hot: string;
|
|
422
470
|
/** Absolute path to the cold (daemon) artifact. */
|
|
423
471
|
readonly cold: string;
|
|
472
|
+
/** Absolute path to the stream (L2/L3 `@stream`) artifact (`release-stream.wasm`). */
|
|
473
|
+
readonly stream: string;
|
|
424
474
|
}
|
|
425
475
|
export function serverArtifacts(root: string): ServerArtifacts {
|
|
426
476
|
let out = 'build/server/release.wasm';
|
|
427
477
|
let hot: string | undefined;
|
|
428
478
|
let cold: string | undefined;
|
|
479
|
+
let stream: string | undefined;
|
|
429
480
|
try {
|
|
430
481
|
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
431
|
-
targets?: Record<
|
|
482
|
+
targets?: Record<
|
|
483
|
+
string,
|
|
484
|
+
{ outFile?: string; hotFile?: string; coldFile?: string; streamFile?: string }
|
|
485
|
+
>;
|
|
432
486
|
};
|
|
433
487
|
out = cfg.targets?.release?.outFile ?? out;
|
|
434
488
|
hot = cfg.targets?.release?.hotFile;
|
|
435
489
|
cold = cfg.targets?.release?.coldFile;
|
|
490
|
+
stream = cfg.targets?.release?.streamFile;
|
|
436
491
|
} catch {
|
|
437
492
|
// No readable toilconfig: caller already gated on its existence; keep defaults.
|
|
438
493
|
}
|
|
439
|
-
const ins = (mode: 'hot' | 'cold'): string => {
|
|
494
|
+
const ins = (mode: 'hot' | 'cold' | 'stream'): string => {
|
|
440
495
|
const ext = path.extname(out);
|
|
441
496
|
return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
|
|
442
497
|
};
|
|
443
498
|
return {
|
|
444
499
|
hot: path.resolve(root, hot ?? ins('hot')),
|
|
445
500
|
cold: path.resolve(root, cold ?? ins('cold')),
|
|
501
|
+
stream: path.resolve(root, stream ?? ins('stream')),
|
|
446
502
|
};
|
|
447
503
|
}
|
|
448
504
|
|