serverless-ircd 0.8.0 → 0.10.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.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -5,22 +5,36 @@
5
5
  * unit-testable without running the (slow) Kiwi upstream build. The build
6
6
  * script delegates here and then validates the selected file against
7
7
  * {@link parseKiwiConfig} before baking it into `dist/webclient/static/config.json`.
8
+ *
9
+ * There is a single Cloudflare deploy target (`prod`); the staging/prod
10
+ * split was collapsed. `--env prod` selects `config.prod.json`; omitting
11
+ * `--env` bakes the dev default (`config.json`). `--env staging` is rejected.
12
+ *
13
+ * AWS has its own per-env config because the WebSocket endpoint is a separate
14
+ * API Gateway URL (`wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}`),
15
+ * not the CloudFront origin serving the SPA. `--env prod-aws` selects
16
+ * `config.prod-aws.json`, whose `server` carries `{{API_ID}}` / `{{REGION}}`
17
+ * / `{{STAGE}}` placeholders the deploy pipeline substitutes at bake time
18
+ * (no concrete URLs in the repo). The `prod-aws` env name is retained for
19
+ * fidelity with the historical staging/prod nomenclature even though the
20
+ * staging/prod split is gone — `aws` alone would read as a cleaner label but
21
+ * is left for a separate rename pass.
8
22
  */
9
23
 
10
24
  /** argv values accepted by `--env`; mapped to a `static/config.<env>.json` file. */
11
25
  export const ENV_TO_CONFIG_FILE = {
12
- staging: 'config.staging.json',
13
26
  prod: 'config.prod.json',
27
+ 'prod-aws': 'config.prod-aws.json',
14
28
  } as const;
15
29
 
16
30
  export type BuildEnv = keyof typeof ENV_TO_CONFIG_FILE;
17
31
 
18
- /** Default config file baked when `--env` is not supplied (staging target). */
32
+ /** Default config file baked when `--env` is not supplied (dev default). */
19
33
  export const DEFAULT_CONFIG_FILE = 'config.json';
20
34
 
21
35
  /**
22
36
  * Resolves which `static/config.*.json` the build should bake from the
23
- * `--env <staging|prod>` CLI flag.
37
+ * `--env prod` CLI flag.
24
38
  *
25
39
  * @param argv the CLI args after the script name (typically `process.argv.slice(2)`)
26
40
  * @returns `{ file, env }` where `file` is the basename under `static/` and
@@ -31,7 +45,7 @@ export function resolveConfigFile(argv: string[]): { file: string; env: string }
31
45
  const envIndex = argv.indexOf('--env');
32
46
  const env = envIndex !== -1 ? argv[envIndex + 1] : undefined;
33
47
  if (env === undefined) {
34
- return { file: DEFAULT_CONFIG_FILE, env: 'default (staging)' };
48
+ return { file: DEFAULT_CONFIG_FILE, env: 'default' };
35
49
  }
36
50
  const file = ENV_TO_CONFIG_FILE[env as BuildEnv];
37
51
  if (file === undefined) {
@@ -41,3 +55,110 @@ export function resolveConfigFile(argv: string[]): { file: string; env: string }
41
55
  }
42
56
  return { file, env };
43
57
  }
58
+
59
+ /**
60
+ * Resolves the optional `--api-url <value>` flag carried on the build argv.
61
+ *
62
+ * The AWS web deploy pipeline reads the deployed stack's `ConnectUrl`
63
+ * CloudFormation output (the API Gateway WebSocket URL) and forwards it
64
+ * to the SPA build via `--api-url`. The build script then overrides
65
+ * `startupOptions.server` in the parsed Kiwi config with this value before
66
+ * baking `dist/webclient/static/config.json`, so the baked SPA carries the
67
+ * real wss URL instead of the `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`
68
+ * template that lives in `static/config.prod-aws.json`. No concrete URLs
69
+ * are committed; the URL is read from stack outputs at deploy time.
70
+ *
71
+ * Returns `undefined` when the flag is absent OR when it is the final
72
+ * token on the argv (no following value). The caller treats both cases
73
+ * identically: leave the config's `server` field untouched. The Cloudflare
74
+ * deploys never pass this flag; only the AWS deploy pipeline does.
75
+ *
76
+ * @param argv the CLI args after the script name (typically `process.argv.slice(2)`)
77
+ * @returns the URL string following `--api-url`, or `undefined` when unset.
78
+ */
79
+ export function resolveApiUrl(argv: string[]): string | undefined {
80
+ const i = argv.indexOf('--api-url');
81
+ if (i === -1) {
82
+ return undefined;
83
+ }
84
+ return argv[i + 1];
85
+ }
86
+
87
+ /**
88
+ * Splits a `wss://` API Gateway WebSocket URL into the two Kiwi fields it
89
+ * becomes at bake time: `server` (hostname only) and `direct_path` (the
90
+ * stage-name path segment).
91
+ *
92
+ * irc-framework builds the WebSocket URL as `wss://<server>:<port><direct_path>`,
93
+ * prepending the `wss://` scheme itself whenever `tls: true`. Setting
94
+ * `server` to a full `wss://...` URL would make irc-framework prepend a
95
+ * second `wss://`, producing a malformed URL that Firefox reports as
96
+ * `wss://wss//<host>...` (the inner `:` becomes a userinfo separator).
97
+ *
98
+ * AWS API Gateway WebSocket URLs include the stage name as a path
99
+ * segment (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`) —
100
+ * that segment must reach the browser as `direct_path`, not as part of
101
+ * `server`. The CF Worker's WS upgrade is at root, so CF configs leave
102
+ * `direct_path` unset (or `/`) and `server` is just the hostname.
103
+ *
104
+ * @param url a `wss://` or `ws://` URL (the stack's `ConnectUrl` output)
105
+ * @returns `{ server, directPath }` where `server` is the bare hostname
106
+ * and `directPath` is the leading-slash path (e.g. `/prod`) or
107
+ * `undefined` when the URL's path is `/` or empty.
108
+ * @throws {Error} when the URL is missing the `ws://`/`wss://` scheme or
109
+ * fails `new URL()` parsing.
110
+ */
111
+ export function parseApiUrlForKiwi(url: string): {
112
+ server: string;
113
+ directPath: string | undefined;
114
+ } {
115
+ if (!/^wss?:\/\//iu.test(url)) {
116
+ throw new Error(
117
+ `--api-url must be a wss:// (or ws://) URL; got: ${url}. irc-framework prepends the scheme itself from tls:true, so the bake must split the URL into a hostname-only server + a direct_path.`,
118
+ );
119
+ }
120
+ const parsed = new URL(url);
121
+ const directPath =
122
+ parsed.pathname === '' || parsed.pathname === '/' ? undefined : parsed.pathname;
123
+ return { server: parsed.hostname, directPath };
124
+ }
125
+
126
+ /**
127
+ * Pattern matching any `{{key}}` placeholder, used by both the Kiwi
128
+ * runtime templates (`{{hostname}}`, `{{host}}`, `{{port}}`) and the
129
+ * AWS deploy-time placeholders (`{{API_ID}}`, `{{REGION}}`,
130
+ * `{{STAGE}}`).
131
+ */
132
+ export const PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/u;
133
+
134
+ /**
135
+ * The Cloudflare deploy envs whose `server` field legitimately bakes
136
+ * Kiwi runtime templates. The placeholder guard must NOT fire on these
137
+ * — `{{hostname}}` is resolved by Kiwi in the browser at load time.
138
+ */
139
+ const RUNTIME_TEMPLATE_ENVS = new Set<string>(['default', 'prod']);
140
+
141
+ /**
142
+ * Returns `true` when a baked Kiwi config carries an unsubstituted
143
+ * placeholder that would produce a broken SPA. The guard is scoped to
144
+ * the `prod-aws` env: that is the only config whose `server` carries
145
+ * AWS deploy-time placeholders (`{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`)
146
+ * that the deploy pipeline (`scripts/deploy-web-aws.mjs`) must
147
+ * substitute via `--api-url` before baking.
148
+ *
149
+ * The Cloudflare envs (`default`, `prod`) use Kiwi's `{{hostname}}`
150
+ * runtime template, which is resolved by Kiwi in the browser at load
151
+ * time and MUST survive the bake. Applying the guard to them would
152
+ * break every default `pnpm build` and every `build:prod`.
153
+ *
154
+ * @param bakedConfigJson the serialised config payload about to be
155
+ * written to `dist/webclient/static/config.json`.
156
+ * @param env the selected build env (the `env` field from
157
+ * {@link resolveConfigFile}).
158
+ */
159
+ export function hasUnsubstitutedPlaceholder(bakedConfigJson: string, env: string): boolean {
160
+ if (RUNTIME_TEMPLATE_ENVS.has(env)) {
161
+ return false;
162
+ }
163
+ return PLACEHOLDER_PATTERN.test(bakedConfigJson);
164
+ }
@@ -83,13 +83,27 @@ export const KiwiStartupOptionsSchema = z
83
83
  /**
84
84
  * WebSocket path on the server. Kiwi reads `direct_path` (NOT `path`) —
85
85
  * upstream `helpers/Misc.js:139` copies `config.direct_path` into the
86
- * connection info; a `path` field is silently ignored. The Worker exposes
87
- * its WS upgrade at root (`worker.ts` routes the upgrade before any path
88
- * dispatch), so any path other than `/` would miss the handler. Optional
89
- * when omitted irc-framework builds `wss://host:port` with no path,
90
- * which the browser normalises to `wss://host:port/`.
86
+ * connection info; a `path` field is silently ignored.
87
+ *
88
+ * Two valid shapes:
89
+ * - CF Worker: WS upgrade is at root, so `direct_path` is `/`.
90
+ * - AWS API Gateway: the WebSocket endpoint URL includes the stage
91
+ * name as a path segment
92
+ * (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`), so
93
+ * `direct_path` MUST be `/<stage>` (e.g. `/prod`). irc-framework
94
+ * builds the WS URL as `wss://<server>:<port><direct_path>`.
95
+ *
96
+ * The constraint accepts a leading slash plus zero or one path segment
97
+ * (no nested paths), allowing alphanumeric stage names and the
98
+ * `{{STAGE}}` placeholder baked into `config.prod-aws.json` for the
99
+ * deploy pipeline to substitute. Optional — when omitted irc-framework
100
+ * builds `wss://host:port` with no path, which the browser normalises
101
+ * to `wss://host:port/` (CF root case).
91
102
  */
92
- direct_path: z.literal('/').optional(),
103
+ direct_path: z
104
+ .string()
105
+ .regex(/^\/(?:[a-zA-Z0-9._-]+|\{\{[^}]+\}\})?$/u, 'direct_path must be / or /<stage>')
106
+ .optional(),
93
107
  })
94
108
  .passthrough();
95
109
 
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Markdown → static-HTML render pipeline for the `docs/` submodule.
3
+ *
4
+ * Renders every `docs/*.md` to a standalone HTML page under `dist/docs/` so
5
+ * the Worker `[assets]` binding serves them at `/docs/<name>.html` (and
6
+ * `/docs/` for the index — `Home.md` becomes `index.html`). Wired into the
7
+ * `apps/web` build by `scripts/build.mjs`.
8
+ *
9
+ * Extracted from the build script so the rendering pipeline is unit-testable
10
+ * without running the (heavy) Kiwi upstream SPA build.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { marked } from 'marked';
17
+
18
+ // GFM is on by default in marked v18+, but pin it explicitly so a future
19
+ // upstream default flip can't silently change the rendered output. `async:
20
+ // false` makes `.parse()` return a plain string (its return type is the
21
+ // union `string | Promise<string>` — narrowed with a type assertion at the
22
+ // call site).
23
+ marked.use({ gfm: true, async: false });
24
+
25
+ /** Basename of the docs landing page (rendered to `index.html`). */
26
+ const HOME_SLUG = 'Home';
27
+
28
+ /**
29
+ * Render GitHub-flavored markdown to an HTML fragment (no surrounding
30
+ * `<html>`/`<body>` — {@link buildDocs} wraps the result in a full page).
31
+ */
32
+ export function renderMarkdown(md: string): string {
33
+ return marked.parse(md, { async: false }) as string;
34
+ }
35
+
36
+ /**
37
+ * Rewrite intra-docs links in already-rendered HTML so relative references
38
+ * resolve on the static site. Two source shapes are handled:
39
+ *
40
+ * 1. `[x](OtherDoc.md)` / `[x](./OtherDoc.md)` — Gitea-relative, emitted by
41
+ * marked as `<a href="./OtherDoc.md">`.
42
+ * 2. `[x](OtherDoc)` — Gitea-wiki "bare" link (no extension), used heavily
43
+ * in `Home.md` and `ADR-Index.md`.
44
+ *
45
+ * Only slugs present in `knownDocs` are rewritten, so external links, repo
46
+ * paths, and unknown targets are left untouched. Anchor fragments
47
+ * (`#section`) are preserved.
48
+ */
49
+ export function rewriteDocLinks(html: string, knownDocs: Set<string>): string {
50
+ return html.replace(/href="([^"]*)"/g, (match, raw: string) => {
51
+ // Leave external URLs and pure-anchor links alone.
52
+ if (raw === '' || raw.startsWith('#') || /^(?:https?:|mailto:|tel:|data:)/i.test(raw)) {
53
+ return match;
54
+ }
55
+ const [pathPartRaw, ...fragParts] = raw.split('#');
56
+ const pathPart = pathPartRaw ?? '';
57
+ const fragment = fragParts.length > 0 ? `#${fragParts.join('#')}` : '';
58
+ const slug = pathPart.replace(/^\.\//, '').replace(/\.md$/i, '').trim();
59
+ if (slug === '' || !knownDocs.has(slug)) {
60
+ return match;
61
+ }
62
+ return `href="${slug}.html${fragment}"`;
63
+ });
64
+ }
65
+
66
+ export interface BuildDocsOptions {
67
+ /** Source directory containing the `*.md` files (typically the `docs/` submodule). */
68
+ srcDir: string;
69
+ /** Output directory to write the rendered site (typically `dist/docs/`). */
70
+ outDir: string;
71
+ }
72
+
73
+ export interface BuildDocsResult {
74
+ /** Relative names of the files written under `outDir`. */
75
+ emitted: string[];
76
+ }
77
+
78
+ /**
79
+ * Render every `*.md` under `srcDir` into a standalone HTML page under
80
+ * `outDir`. `Home.md` becomes `index.html`; every other file `<Slug>.md`
81
+ * becomes `<Slug>.html`. Throws a clear error if `srcDir` is missing or
82
+ * empty — the build script surfaces this so a stale `docs/` submodule
83
+ * checkout fails loudly (mirroring the Kiwi `upstream/` guard).
84
+ */
85
+ export async function buildDocs({ srcDir, outDir }: BuildDocsOptions): Promise<BuildDocsResult> {
86
+ if (!existsSync(srcDir)) {
87
+ throw new Error(
88
+ `Docs source missing at ${srcDir}. Run \`git submodule update --init docs\` first.`,
89
+ );
90
+ }
91
+ const entries = await readdir(srcDir);
92
+ const mdFiles = entries.filter((f) => f.toLowerCase().endsWith('.md'));
93
+ if (mdFiles.length === 0) {
94
+ throw new Error(
95
+ `Docs source directory is empty at ${srcDir}. Run \`git submodule update --init docs\` first.`,
96
+ );
97
+ }
98
+
99
+ const knownSlugs = new Set(mdFiles.map((f) => f.replace(/\.md$/i, '')));
100
+ await mkdir(outDir, { recursive: true });
101
+
102
+ // Shared stylesheet — single source of truth for every page, mirroring the
103
+ // landing page's dark theme (CSS variables kept inline so the rendered
104
+ // docs work as static files served from any path).
105
+ await writeFile(path.join(outDir, 'docs.css'), DOCS_CSS, 'utf8');
106
+
107
+ const emitted: string[] = [];
108
+ for (const file of mdFiles) {
109
+ const slug = file.replace(/\.md$/i, '');
110
+ const md = await readFile(path.join(srcDir, file), 'utf8');
111
+ const body = rewriteDocLinks(renderMarkdown(md), knownSlugs);
112
+ const title = extractTitle(md) ?? slug;
113
+ const outName = slug === HOME_SLUG ? 'index.html' : `${slug}.html`;
114
+ const html = wrapPage(title, body, slug === HOME_SLUG);
115
+ await writeFile(path.join(outDir, outName), html, 'utf8');
116
+ emitted.push(outName);
117
+ }
118
+
119
+ return { emitted };
120
+ }
121
+
122
+ /** Extract the first ATX `# Heading` as the page title; `undefined` if none. */
123
+ function extractTitle(md: string): string | undefined {
124
+ const match = md.match(/^#\s+(.+?)\s*$/m);
125
+ return match?.[1];
126
+ }
127
+
128
+ /** Escape the four significant HTML characters in a text string. */
129
+ function escapeHtml(s: string): string {
130
+ return s
131
+ .replace(/&/g, '&amp;')
132
+ .replace(/</g, '&lt;')
133
+ .replace(/>/g, '&gt;')
134
+ .replace(/"/g, '&quot;');
135
+ }
136
+
137
+ /** Wrap a rendered HTML fragment in a full page with the shared stylesheet. */
138
+ function wrapPage(title: string, body: string, isIndex: boolean): string {
139
+ const back = isIndex ? '' : '<p class="back"><a href="./index.html">Docs home</a></p>';
140
+ return `<!doctype html>
141
+ <html lang="en">
142
+ <head>
143
+ <meta charset="utf-8" />
144
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
145
+ <title>${escapeHtml(title)} — ServerlessIRCd Docs</title>
146
+ <link rel="stylesheet" href="./docs.css" />
147
+ </head>
148
+ <body>
149
+ <header>
150
+ <div class="container">
151
+ <h1><a href="./index.html">ServerlessIRCd Docs</a></h1>
152
+ </div>
153
+ </header>
154
+ <main class="container">
155
+ ${back}
156
+ <article>
157
+ ${body}
158
+ </article>
159
+ </main>
160
+ <footer>
161
+ <div class="container">
162
+ <p><a href="/">Back to landing</a></p>
163
+ </div>
164
+ </footer>
165
+ </body>
166
+ </html>
167
+ `;
168
+ }
169
+
170
+ const DOCS_CSS = `:root {
171
+ --bg: #0d1117;
172
+ --surface: #161b22;
173
+ --border: #30363d;
174
+ --text: #e6edf3;
175
+ --muted: #8b949e;
176
+ --accent: #58a6ff;
177
+ --accent-hover: #79c0ff;
178
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
179
+ "Liberation Mono", monospace;
180
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
181
+ sans-serif;
182
+ }
183
+ * { box-sizing: border-box; }
184
+ html { -webkit-text-size-adjust: 100%; }
185
+ body {
186
+ margin: 0;
187
+ font-family: var(--sans);
188
+ line-height: 1.6;
189
+ color: var(--text);
190
+ background-color: var(--bg);
191
+ display: flex;
192
+ flex-direction: column;
193
+ min-height: 100vh;
194
+ }
195
+ .container {
196
+ width: 100%;
197
+ max-width: 820px;
198
+ margin: 0 auto;
199
+ padding: 0 20px;
200
+ }
201
+ header {
202
+ padding: 24px 0 14px;
203
+ border-bottom: 1px solid var(--border);
204
+ }
205
+ header h1 {
206
+ margin: 0;
207
+ font-size: 1.4rem;
208
+ letter-spacing: -0.02em;
209
+ }
210
+ header h1 a {
211
+ color: var(--accent);
212
+ text-decoration: none;
213
+ }
214
+ header h1 a:hover { text-decoration: underline; }
215
+ main { flex: 1 0 auto; padding: 28px 0; }
216
+ article {
217
+ font-size: 1rem;
218
+ overflow-wrap: break-word;
219
+ }
220
+ article h1, article h2, article h3, article h4 {
221
+ letter-spacing: -0.01em;
222
+ line-height: 1.25;
223
+ margin-top: 1.6em;
224
+ margin-bottom: 0.6em;
225
+ }
226
+ article h1 { font-size: 1.7rem; margin-top: 0; }
227
+ article h2 { font-size: 1.35rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
228
+ article h3 { font-size: 1.12rem; }
229
+ article h4 { font-size: 1rem; color: var(--accent-hover); }
230
+ article p { margin: 0.8em 0; }
231
+ article a { color: var(--accent); text-decoration: none; }
232
+ article a:hover { text-decoration: underline; }
233
+ article ul, article ol { padding-left: 1.6em; }
234
+ article li { margin: 0.25em 0; }
235
+ article blockquote {
236
+ margin: 0.9em 0;
237
+ padding: 0.4em 1em;
238
+ border-left: 3px solid var(--border);
239
+ color: var(--muted);
240
+ }
241
+ article blockquote p { margin: 0.4em 0; }
242
+ article code {
243
+ font-family: var(--mono);
244
+ background-color: var(--surface);
245
+ padding: 2px 6px;
246
+ border-radius: 4px;
247
+ font-size: 0.9em;
248
+ }
249
+ article pre {
250
+ font-family: var(--mono);
251
+ background-color: var(--surface);
252
+ border: 1px solid var(--border);
253
+ border-radius: 8px;
254
+ padding: 14px 16px;
255
+ margin: 12px 0;
256
+ overflow-x: auto;
257
+ line-height: 1.5;
258
+ font-size: 0.88rem;
259
+ }
260
+ article pre code {
261
+ background: none; border: none; padding: 0; font-size: inherit;
262
+ }
263
+ article table {
264
+ border-collapse: collapse;
265
+ margin: 1em 0;
266
+ display: block;
267
+ overflow-x: auto;
268
+ }
269
+ article table th, article table td {
270
+ border: 1px solid var(--border);
271
+ padding: 6px 12px;
272
+ text-align: left;
273
+ }
274
+ article table th { background-color: var(--surface); }
275
+ article hr {
276
+ border: none;
277
+ border-top: 1px solid var(--border);
278
+ margin: 1.6em 0;
279
+ }
280
+ article img { max-width: 100%; }
281
+ .back { margin-top: 0; color: var(--muted); font-size: 0.92rem; }
282
+ .back a { color: var(--accent); }
283
+ footer {
284
+ flex-shrink: 0;
285
+ padding: 18px 0;
286
+ border-top: 1px solid var(--border);
287
+ color: var(--muted);
288
+ font-size: 0.9rem;
289
+ }
290
+ footer a { color: var(--muted); text-decoration: none; }
291
+ footer a:hover { color: var(--accent); }
292
+ `;
@@ -1,5 +1,5 @@
1
1
  {
2
- "windowTitle": "ServerlessIRCd (staging)",
2
+ "windowTitle": "ServerlessIRCd",
3
3
  "startupScreen": "welcome",
4
4
  "restricted": false,
5
5
  "theme": "Dark",
@@ -15,7 +15,8 @@
15
15
  { "name": "Elite", "url": "static/themes/elite" }
16
16
  ],
17
17
  "startupOptions": {
18
- "server": "{{hostname}}",
18
+ "server": "{{API_ID}}.execute-api.{{REGION}}.amazonaws.com",
19
+ "direct_path": "/{{STAGE}}",
19
20
  "direct": true,
20
21
  "tls": true,
21
22
  "port": 443,