@teamscala/service-boot 0.23.349 → 0.23.352

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 (39) hide show
  1. package/package.json +479 -567
  2. package/src/agreement-gate.ts +1 -1
  3. package/src/auth-gate.ts +0 -11
  4. package/src/fn-routers/index.ts +1 -12
  5. package/src/fn-routers/public-agreement-router.ts +37 -49
  6. package/src/mount-orpc-router.test.ts +46 -0
  7. package/src/mount-orpc-router.ts +16 -3
  8. package/src/mutable-app-ref.ts +0 -20
  9. package/src/page-server-orpc/puck-pages-router.ts +0 -102
  10. package/src/register-page-server-client.ts +0 -4
  11. package/src/register-ws-routes.ts +10 -3
  12. package/src/two-phase-boot.ts +2 -1
  13. package/src/ui-service.ts +11 -18
  14. package/src/fn-routers/ai-health-router.ts +0 -64
  15. package/src/fn-routers/panel-data-router.ts +0 -93
  16. package/src/fn-routers/plans-router.ts +0 -141
  17. package/src/fn-routers/task-sync-assign.test.ts +0 -483
  18. package/src/fn-routers/task-sync-assign.ts +0 -485
  19. package/src/fn-routers/task-sync-bind.test.ts +0 -110
  20. package/src/fn-routers/task-sync-brief.test.ts +0 -120
  21. package/src/fn-routers/task-sync-brief.ts +0 -151
  22. package/src/fn-routers/task-sync-classify.test.ts +0 -109
  23. package/src/fn-routers/task-sync-close.test.ts +0 -44
  24. package/src/fn-routers/task-sync-close.ts +0 -82
  25. package/src/fn-routers/task-sync-created-status.test.ts +0 -58
  26. package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
  27. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  28. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  29. package/src/fn-routers/task-sync-projection.test.ts +0 -172
  30. package/src/fn-routers/task-sync-projection.ts +0 -273
  31. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  32. package/src/fn-routers/task-sync-rename.ts +0 -111
  33. package/src/fn-routers/tasks-router.ts +0 -77
  34. package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
  35. package/src/page-server-orpc/templates-router.ts +0 -116
  36. package/src/serve/serve-ws.test.ts +0 -135
  37. package/src/serve/serve.ts +0 -148
  38. package/src/serve/stamp-transport-identity.test.ts +0 -24
  39. package/src/serve/stamp-transport-identity.ts +0 -109
@@ -1,53 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit directly
5
- *
6
- * Unit tests for the rename back-write leg: renameOutcome (the pure
7
- * no-task ⇒ not-updated decision) + source-contract assertions that the handler
8
- * resolves the lane task BY SESSION (never pane) and updates its title. Covers
9
- * `what-must-have-a-test` for the new procedure + the `lane-identity-is-cli-
10
- * session-id` invariant it rides on.
11
- */
12
- import { describe, it, expect } from "bun:test";
13
- import { renameOutcome } from "./task-sync-rename.ts";
14
-
15
- describe("renameOutcome (no verdict from no data)", () => {
16
- it("returns not-updated with a null task_id when no task is bound to the session", () => {
17
- // A rename of a tab whose lane has no resolvable task updates nothing —
18
- // never an error, never a fake id. updated:false is the signal the caller
19
- // treats as best-effort (the window was already renamed; this only missed
20
- // the DB echo, which is the rare archived-and-pruned case).
21
- expect(renameOutcome(null)).toEqual({ task_id: null, updated: false });
22
- });
23
-
24
- it("returns the bound task id + updated:true when a task resolves", () => {
25
- expect(renameOutcome({ id: "task-9" })).toEqual({ task_id: "task-9", updated: true });
26
- });
27
- });
28
-
29
- describe("renameLaneTask resolves BY SESSION and writes the title (source contract)", () => {
30
- const renameSrc = Bun.file(new URL("./task-sync-rename.ts", import.meta.url).pathname);
31
-
32
- it("resolves the lane task by lane_session (the CLI session id), never by pane", async () => {
33
- // `lane-identity-is-cli-session-id`: binding is by session because a pane
34
- // outlives the session inside it. A pane-keyed rename would let a dead
35
- // session's task answer for a live lane.
36
- const text = await renameSrc.text();
37
- expect(text).toContain("resolveLaneTask(prisma, lane_session)");
38
- // No pane matching anywhere in the resolution path.
39
- expect(text).not.toContain("lane.pane");
40
- });
41
-
42
- it("updates the resolved task's title by id", async () => {
43
- const text = await renameSrc.text();
44
- expect(text).toContain('where: { id: task.id }');
45
- expect(text).toContain("data: { title }");
46
- });
47
-
48
- it("requires lane_session + title at the signature (incomplete rename unconstructible)", async () => {
49
- const text = await renameSrc.text();
50
- expect(text).toContain('lane_session: v.pipe(v.string()');
51
- expect(text).toContain('title: v.pipe(v.string()');
52
- });
53
- });
@@ -1,111 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Lane RENAME back-write for task-sync: a tab rename EMITS to update the bound
7
- * work_items task's title in the DB, by CLI session id. This is the DB half of
8
- * `tab-rename-emits-back-to-task-title`: the operator's tab bar is how they see
9
- * what is being worked on (`lane-labels-and-task-lists`), so the tmux window
10
- * name and the task title must never diverge silently. Renaming a tab from
11
- * `scala-tools session rename` (or the lane renaming itself through the same
12
- * surface) updates the task title so the project board agrees with the tab bar.
13
- *
14
- * THE EMISSION, not a poll (`actions-are-rows-then-emissions`): a caller renames
15
- * the window (a row/state change) and that change emits the title update — the
16
- * task-sync owner reacts by updating the row. State lives in the row, never in a
17
- * return value a caller may never receive.
18
- *
19
- * Bound by CLI SESSION ID, never pane id (`lane-identity-is-cli-session-id`): a
20
- * pane OUTLIVES the session inside it, so matching on a pane would let a dead
21
- * session's task answer for a live lane. The session is the stable identity a
22
- * rename resolves to (the daemon resolves the pane's tab → its stored session id
23
- * and passes that here), so the rename lands on the lane that owns the tab.
24
- *
25
- * Split out of task-sync-router.ts for the same reason task-sync-brief/lane/
26
- * assign exist: the router sits at its 1000-line commit gate, so every cohesive
27
- * task-sync unit that can live in a sibling does. This owns the rename handler +
28
- * its pure outcome decision.
29
- *
30
- * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
31
- * candidate. Bound to the work_items schema and to task-sync's own
32
- * kind_data.lane.session convention; no unrelated app could install this
33
- * unmodified.
34
- */
35
-
36
- import * as v from "valibot";
37
- import type { Prisma } from "./task-sync-router.ts";
38
- import { authed, type Context } from "./auth-router.ts";
39
- import { resolveLaneTask } from "./task-sync-lane.ts";
40
- import { getAppLogger } from "@teamscala/logger/app-loggers";
41
-
42
- /**
43
- * renameLaneTask ORPC input. `lane_session` is the lane's CLI-native session id
44
- * (kind_data.lane.session) — the stable identity a pane rename resolves to.
45
- * `title` is the new task title (the renamed tab's label). Both required:
46
- * oRPC rejects a call missing either at the signature
47
- * (`prevention-over-detection`), so a malformed rename is a 400, never a silent
48
- * no-op that leaves the title stale.
49
- */
50
- export const renameLaneTaskInput = v.object({
51
- lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
52
- title: v.pipe(v.string(), v.minLength(1, "title is required")),
53
- });
54
-
55
- /**
56
- * The rename outcome from a resolved lane task — pure so the "no task bound ⇒
57
- * not updated, never an error" decision is unit-testable with no DB. A rename
58
- * whose lane has no task is best-effort (returns updated:false): every spawn
59
- * now mints a task, so this is the rare case (a tab whose task was archived and
60
- * pruned), not a path the operator hits. Failing loudly here would refuse a
61
- * rename of the WINDOW for a DB miss, which couples two surfaces that must not
62
- * be — the tmux rename already succeeded before this is called.
63
- */
64
- export function renameOutcome(task: { id: string } | null): {
65
- task_id: string | null;
66
- updated: boolean;
67
- } {
68
- if (!task) return { task_id: null, updated: false };
69
- return { task_id: task.id, updated: true };
70
- }
71
-
72
- /**
73
- * renameLaneTask ORPC handler. The session-picker daemon calls this from
74
- * `/rpc/rename` after it renames the tmux window: it resolves the pane's tab to
75
- * its session id and posts it here. Same transport as bindLane/readTaskList:
76
- * page-gate-exempt (fn.taskSync), localhost super-admin. Resolves the lane's
77
- * task BY SESSION (never pane) and updates its title. Idempotent: renaming to
78
- * the same title writes the same row.
79
- *
80
- * An archived parent is matched too (resolveLaneTask falls back to the archived
81
- * lane task for the session), so a rename of a tab whose run closed still lands
82
- * on its task — the title is the title regardless of run state.
83
- */
84
- export const renameLaneTaskHandler = authed
85
- .input(renameLaneTaskInput)
86
- .handler(async ({ input, context }) => {
87
- const ctx = context as Context;
88
- const prisma = ctx.prisma as Prisma;
89
- const { lane_session, title } = input;
90
- const task = await resolveLaneTask(prisma, lane_session);
91
- const outcome = renameOutcome(task);
92
- if (!task) return outcome;
93
- try {
94
- await prisma.work_items.update({
95
- where: { id: task.id },
96
- data: { title },
97
- select: { id: true },
98
- });
99
- } catch (err) {
100
- // The window was already renamed by the caller; a DB update failure must
101
- // not surface as an error that implies the rename failed. Report
102
- // not-updated + log, so the two surfaces stay decoupled
103
- // (`a-component-may-not-report-a-state-it-has-not-verified`).
104
- getAppLogger().warn("[task-sync] renameLaneTask: title update failed", {
105
- taskId: task.id,
106
- error: err instanceof Error ? err.message : String(err),
107
- });
108
- return { task_id: task.id, updated: false };
109
- }
110
- return outcome;
111
- });
@@ -1,77 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedures for task creation with initial comment.
7
- * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
- * contain application-specific work_items logic.
9
- */
10
-
11
- import { authed, type Context } from "./auth-router.ts";
12
-
13
- function toSession(ctx: Context): { userId: string; orgId: string | null } {
14
- const user = ctx.user as { id: string; organisationId?: string | null };
15
- return {
16
- userId: user.id,
17
- orgId: ctx.orgId || user.organisationId || null,
18
- };
19
- }
20
-
21
- export const tasksRouter = {
22
- createWithComment: authed.handler(async ({ input, context }) => {
23
- const data = input as {
24
- project_id: string;
25
- title: string;
26
- initial_comment: string;
27
- status?: string;
28
- assignee_id?: string;
29
- due_date?: string;
30
- start_date?: string;
31
- time_estimate?: number;
32
- assignee_type?: string;
33
- agent_assignee_id?: string;
34
- automation_type?: string;
35
- responsible_party?: string;
36
- depends_on?: string[];
37
- };
38
-
39
- if (!data.project_id) throw new Error("project_id is required");
40
- if (!data.title) throw new Error("title is required");
41
- if (!data.initial_comment) throw new Error("initial_comment is required");
42
-
43
- const userId = toSession(context).userId;
44
- const result = await (context.prisma as any).$transaction(
45
- async (tx: any) => {
46
- const task = await tx.work_items.create({
47
- data: {
48
- kind: "task",
49
- project_id: data.project_id,
50
- title: data.title,
51
- status: data.status || "todo",
52
- assignee_id: data.assignee_id || null,
53
- due_date: data.due_date || null,
54
- start_date: data.start_date || null,
55
- time_estimate: data.time_estimate || null,
56
- assignee_type: data.assignee_type || "user",
57
- agent_assignee_id: data.agent_assignee_id || null,
58
- automation_type: data.automation_type || "manual",
59
- responsible_party: data.responsible_party || "team",
60
- depends_on: data.depends_on || [],
61
- created_by: userId,
62
- updated_by: userId,
63
- },
64
- });
65
- await tx.comments.create({
66
- data: {
67
- work_item_id: task.id,
68
- content: data.initial_comment,
69
- user_id: userId,
70
- },
71
- });
72
- return task;
73
- },
74
- );
75
- return { success: true, data: result };
76
- }),
77
- };
@@ -1,170 +0,0 @@
1
- /**
2
- * @system page-server
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedures for portal diagnostics. These replace direct Prisma calls
7
- * in the portal diagnostic section files with proper ORPC procedures that
8
- * receive context.prisma from the router.
9
- */
10
-
11
- import { os } from "@orpc/server";
12
- import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
13
- import { loadRegistryConfigsByType } from "@teamscala/db/registry/load-config";
14
- import * as v from "valibot";
15
-
16
- const procedure = os;
17
-
18
- const PortalWebsiteSchema = v.object({
19
- id: v.string(),
20
- name: v.string(),
21
- organisation_id: v.nullable(v.string()),
22
- });
23
-
24
- const MemberSchema = v.object({
25
- user_id: v.string(),
26
- });
27
-
28
- const PageRowSchema = v.object({
29
- id: v.string(),
30
- path: v.nullable(v.string()),
31
- name: v.string(),
32
- puck_data: v.unknown(),
33
- updated_at: v.string(),
34
- });
35
-
36
- const BlockSchemaSlugSchema = v.object({
37
- slug: v.string(),
38
- });
39
-
40
- export const portalDiagRouter = {
41
- findPortalWebsite: procedure
42
- .input(v.object({}))
43
- .output(v.nullable(PortalWebsiteSchema))
44
- .handler(async ({ context }) => {
45
- const db = (context as { prisma?: unknown }).prisma;
46
- if (!db)
47
- throw new Error("portalDiag.findPortalWebsite requires context.prisma");
48
-
49
- return (await (
50
- db as {
51
- builda_websites: { findFirst: (args: unknown) => Promise<unknown> };
52
- }
53
- ).builda_websites.findFirst({
54
- where: { website_type: "portal" },
55
- select: { id: true, name: true, organisation_id: true },
56
- orderBy: { updated_at: "desc" },
57
- })) as {
58
- id: string;
59
- name: string;
60
- organisation_id: string | null;
61
- } | null;
62
- }),
63
-
64
- findMember: procedure
65
- .input(v.object({ organisationId: v.string() }))
66
- .output(v.nullable(MemberSchema))
67
- .handler(async ({ input, context }) => {
68
- const db = (context as { prisma?: unknown }).prisma;
69
- if (!db) throw new Error("portalDiag.findMember requires context.prisma");
70
-
71
- return (await (
72
- db as {
73
- better_auth_member: {
74
- findFirst: (args: unknown) => Promise<unknown>;
75
- };
76
- }
77
- ).better_auth_member.findFirst({
78
- where: { organization_id: input.organisationId },
79
- select: { user_id: true },
80
- })) as { user_id: string } | null;
81
- }),
82
-
83
- countPages: procedure
84
- .input(v.object({ websiteId: v.string() }))
85
- .output(v.number())
86
- .handler(async ({ input, context }) => {
87
- const db = (context as { prisma?: unknown }).prisma;
88
- if (!db) throw new Error("portalDiag.countPages requires context.prisma");
89
-
90
- return await (
91
- db as { builda_pages: { count: (args: unknown) => Promise<number> } }
92
- ).builda_pages.count({
93
- where: { website_id: input.websiteId },
94
- });
95
- }),
96
-
97
- findPages: procedure
98
- .input(
99
- v.object({
100
- websiteId: v.string(),
101
- path: v.optional(v.string()),
102
- take: v.optional(v.number()),
103
- orderBy: v.optional(v.string()),
104
- }),
105
- )
106
- .output(v.array(PageRowSchema))
107
- .handler(async ({ input, context }) => {
108
- const db = (context as { prisma?: unknown }).prisma;
109
- if (!db) throw new Error("portalDiag.findPages requires context.prisma");
110
-
111
- const where: Record<string, unknown> = { website_id: input.websiteId };
112
- if (input.path) where.path = input.path;
113
-
114
- return (await (
115
- db as {
116
- builda_pages: { findMany: (args: unknown) => Promise<unknown[]> };
117
- }
118
- ).builda_pages.findMany({
119
- where,
120
- select: {
121
- id: true,
122
- path: true,
123
- name: true,
124
- puck_data: true,
125
- updated_at: true,
126
- },
127
- take: input.take,
128
- orderBy: input.orderBy ? { [input.orderBy]: "desc" } : undefined,
129
- })) as Array<{
130
- id: string;
131
- path: string | null;
132
- name: string;
133
- puck_data: unknown;
134
- updated_at: string;
135
- }>;
136
- }),
137
-
138
- findBlockSchemas: procedure
139
- .input(v.object({ slugs: v.array(v.string()) }))
140
- .output(v.array(BlockSchemaSlugSchema))
141
- .handler(async ({ input }) => {
142
- const all = await loadRegistryConfigsByType(
143
- "block_schema",
144
- BlockSchemaConfigSchema,
145
- );
146
- return all
147
- .filter((r) => input.slugs.includes(r.slug))
148
- .map((r) => ({ slug: r.slug }));
149
- }),
150
-
151
- findWebsites: procedure
152
- .input(v.object({ take: v.optional(v.number()) }))
153
- .output(v.array(v.object({ id: v.string(), name: v.string() })))
154
- .handler(async ({ input, context }) => {
155
- const db = (context as { prisma?: unknown }).prisma;
156
- if (!db)
157
- throw new Error("portalDiag.findWebsites requires context.prisma");
158
-
159
- return (await (
160
- db as {
161
- builda_websites: { findMany: (args: unknown) => Promise<unknown[]> };
162
- }
163
- ).builda_websites.findMany({
164
- select: { id: true, name: true },
165
- take: input.take ?? 10,
166
- })) as Array<{ id: string; name: string }>;
167
- }),
168
- };
169
-
170
- export type PortalDiagRouter = typeof portalDiagRouter;
@@ -1,116 +0,0 @@
1
- /**
2
- * @system page-server
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedure: templates.getSettings(slug). Reads the registry_entries row
7
- * of type='template_settings' for the requested slug, validates against the
8
- * matching Valibot schema from db-validation, returns typed settings.
9
- *
10
- * Per data-access.md: every data read goes through ORPC. The Template
11
- * components do not call Prisma or registry directly — they receive the
12
- * typed result of this procedure as a prop.
13
- *
14
- * Server-side (SSR) calls land here in-process via createRouterClient.
15
- * Client-side calls would land here over HTTP via createORPCClient.
16
- */
17
-
18
- import { os } from "@orpc/server";
19
- import { TemplateSettingsPortalsConfigSchema } from "@teamscala/db-validation/registry-schemas/template-settings-portals";
20
- import { TemplateSettingsPublicConfigSchema } from "@teamscala/db-validation/registry-schemas/template-settings-public";
21
- import { getEntry } from "@teamscala/db/registry/accessors";
22
- import * as v from "valibot";
23
-
24
- const SCHEMA_BY_SLUG: Record<
25
- string,
26
- v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
27
- > = {
28
- "portals-template": TemplateSettingsPortalsConfigSchema,
29
- "public-template": TemplateSettingsPublicConfigSchema,
30
- };
31
-
32
- const procedure = os;
33
-
34
- export const templatesRouter = {
35
- getSettings: procedure
36
- .input(v.object({ slug: v.string() }))
37
- .handler(async ({ input }) => {
38
- const schema = SCHEMA_BY_SLUG[input.slug];
39
- if (!schema) {
40
- throw new Error(
41
- `templates.getSettings: unknown template slug "${input.slug}"`,
42
- );
43
- }
44
- const entry = getEntry("template_settings" as never, input.slug);
45
- if (!entry) {
46
- throw new Error(
47
- `templates.getSettings: row missing for slug "${input.slug}"`,
48
- );
49
- }
50
- const config =
51
- typeof entry.config === "string"
52
- ? JSON.parse(entry.config)
53
- : entry.config;
54
- return v.parse(schema, config);
55
- }),
56
-
57
- /**
58
- * Patch a template's settings row. The patch is a deep partial applied
59
- * over the existing config; the merged result is validated against the
60
- * matching Valibot schema before write. Used by chrome layout (pane
61
- * widths, toolbar height, etc.) so resizes persist server-side and the
62
- * next SSR render picks them up directly — no localStorage, no flash.
63
- */
64
- updateSettings: procedure
65
- .input(
66
- v.object({ slug: v.string(), patch: v.record(v.string(), v.unknown()) }),
67
- )
68
- .handler(async ({ input }) => {
69
- const schema = SCHEMA_BY_SLUG[input.slug];
70
- if (!schema) {
71
- throw new Error(
72
- `templates.updateSettings: unknown template slug "${input.slug}"`,
73
- );
74
- }
75
- const entry = getEntry("template_settings" as never, input.slug);
76
- if (!entry) {
77
- throw new Error(
78
- `templates.updateSettings: row missing for slug "${input.slug}"`,
79
- );
80
- }
81
- const current =
82
- typeof entry.config === "string"
83
- ? JSON.parse(entry.config)
84
- : entry.config;
85
- const merged = deepMerge(current as Record<string, unknown>, input.patch);
86
- const validated = v.parse(schema, merged);
87
- return validated;
88
- }),
89
- };
90
-
91
- function deepMerge(
92
- target: Record<string, unknown>,
93
- patch: Record<string, unknown>,
94
- ): Record<string, unknown> {
95
- const out: Record<string, unknown> = { ...target };
96
- for (const [key, value] of Object.entries(patch)) {
97
- if (
98
- value &&
99
- typeof value === "object" &&
100
- !Array.isArray(value) &&
101
- target[key] &&
102
- typeof target[key] === "object" &&
103
- !Array.isArray(target[key])
104
- ) {
105
- out[key] = deepMerge(
106
- target[key] as Record<string, unknown>,
107
- value as Record<string, unknown>,
108
- );
109
- } else {
110
- out[key] = value;
111
- }
112
- }
113
- return out;
114
- }
115
-
116
- export type TemplatesRouter = typeof templatesRouter;
@@ -1,135 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Unit test for serve.ts WebSocket upgrade path. Spins up a real
7
- * Hono + createBunWebSocket + Bun.serve, registers a WS route, connects
8
- * a client, and asserts message round-trip.
9
- *
10
- * Catches the class of bugs where fetchWithPeerPort clones the Request
11
- * on WS upgrade (commit 43bb02b8e fixed this). If any future change to
12
- * serve.ts breaks upgrade identity, this test fails.
13
- */
14
-
15
- import { afterEach, expect, test } from "bun:test";
16
- import { Hono } from "hono";
17
- import { createBunWebSocket } from "hono/bun";
18
- import type { ServeHandle } from "./serve.ts";
19
- import { serveStart } from "./serve.ts";
20
-
21
- const TEST_PORT = 49152;
22
- const WS_PATH = "/ws-test";
23
-
24
- let handle: ServeHandle | null = null;
25
-
26
- function setupTestServer(): ServeHandle {
27
- const app = new Hono();
28
- const { upgradeWebSocket } = createBunWebSocket();
29
-
30
- app.get(
31
- WS_PATH,
32
- upgradeWebSocket(() => ({
33
- onOpen(_evt, ws) {
34
- const raw = ws.raw as { send: (data: string) => void } | undefined;
35
- if (raw) {
36
- raw.send("hello-from-server");
37
- }
38
- },
39
- onMessage(evt, ws) {
40
- const raw = ws.raw as { send: (data: string) => void } | undefined;
41
- if (raw && typeof evt.data === "string") {
42
- raw.send(`echo:${evt.data}`);
43
- }
44
- },
45
- })),
46
- );
47
-
48
- const h = serveStart(app, {
49
- port: TEST_PORT,
50
- serviceSlug: "ws-test",
51
- logger: { info: () => {}, warn: () => {} },
52
- });
53
- handle = h;
54
- return h;
55
- }
56
-
57
- afterEach(() => {
58
- if (handle) {
59
- handle.stop();
60
- handle = null;
61
- }
62
- });
63
-
64
- test("WS upgrade succeeds and messages round-trip", async () => {
65
- setupTestServer();
66
-
67
- const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}${WS_PATH}`);
68
-
69
- const openPromise = new Promise<void>((resolve, reject) => {
70
- const timer = setTimeout(
71
- () => reject(new Error("WS connection timed out")),
72
- 5000,
73
- );
74
- ws.addEventListener("open", () => {
75
- clearTimeout(timer);
76
- resolve();
77
- });
78
- ws.addEventListener("error", (evt) => {
79
- clearTimeout(timer);
80
- reject(new Error(`WS error: ${String(evt)}`));
81
- });
82
- });
83
-
84
- await openPromise;
85
-
86
- const greeting = await new Promise<string>((resolve, reject) => {
87
- const timer = setTimeout(
88
- () => reject(new Error("greeting message timed out")),
89
- 5000,
90
- );
91
- ws.addEventListener("message", (evt) => {
92
- clearTimeout(timer);
93
- resolve(String(evt.data));
94
- });
95
- });
96
- expect(greeting).toBe("hello-from-server");
97
-
98
- const echoPromise = new Promise<string>((resolve, reject) => {
99
- const timer = setTimeout(
100
- () => reject(new Error("echo message timed out")),
101
- 5000,
102
- );
103
- ws.addEventListener("message", (evt) => {
104
- clearTimeout(timer);
105
- resolve(String(evt.data));
106
- });
107
- });
108
- ws.send("ping");
109
- const echo = await echoPromise;
110
- expect(echo).toBe("echo:ping");
111
-
112
- ws.close();
113
- });
114
-
115
- test("WS upgrade rejects on unknown path", async () => {
116
- setupTestServer();
117
-
118
- const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}/nonexistent`);
119
-
120
- const result = await new Promise<{ ok: boolean; code?: number }>(
121
- (resolve) => {
122
- const timer = setTimeout(() => resolve({ ok: true }), 5000);
123
- ws.addEventListener("error", () => {
124
- clearTimeout(timer);
125
- resolve({ ok: false });
126
- });
127
- ws.addEventListener("close", (evt) => {
128
- clearTimeout(timer);
129
- resolve({ ok: false, code: evt.code });
130
- });
131
- },
132
- );
133
-
134
- expect(result.ok).toBe(false);
135
- });